Compare commits
95 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
6d2dd585a5 | |
|
|
28952a7bb0 | |
|
|
b9df743e04 | |
|
|
8d2d557da9 | |
|
|
36f647e875 | |
|
|
d2a459b7c2 | |
|
|
bbbb52ed75 | |
|
|
ed91bdadd6 | |
|
|
508cc8300a | |
|
|
0aa016f948 | |
|
|
61f74158be | |
|
|
d3128243ec | |
|
|
62df12c040 | |
|
|
56896311a6 | |
|
|
2ebbe39b9e | |
|
|
0af32d06b5 | |
|
|
5c9ceab0bf | |
|
|
537b304cd6 | |
|
|
7203f735c8 | |
|
|
877206660d | |
|
|
1658c86eba | |
|
|
5ecb00fd48 | |
|
|
6a2eb6757b | |
|
|
e8bcd4031c | |
|
|
d6df0d145c | |
|
|
a04730650f | |
|
|
262c8517be | |
|
|
7071a22cb5 | |
|
|
a586b47dc9 | |
|
|
c8efb2a8c9 | |
|
|
62ed472d9e | |
|
|
436bc4c2b9 | |
|
|
0993fef615 | |
|
|
3cf81f64bd | |
|
|
15dc72e26f | |
|
|
c583505430 | |
|
|
d8d232e5a2 | |
|
|
8dd810521c | |
|
|
24f883904d | |
|
|
3ff4b6c062 | |
|
|
630c5981b7 | |
|
|
fd326d6af4 | |
|
|
3184187f2d | |
|
|
ee82baadde | |
|
|
e974d58615 | |
|
|
1a94e7b016 | |
|
|
bdc1dc4363 | |
|
|
055281febf | |
|
|
64edc95e92 | |
|
|
359d735213 | |
|
|
e10eb8a2fd | |
|
|
8b047fb570 | |
|
|
f764c070bd | |
|
|
d06c9c7aae | |
|
|
eb0814c680 | |
|
|
7aff259fee | |
|
|
a2b6a9cf99 | |
|
|
4cb3aaece4 | |
|
|
7a3bbe0107 | |
|
|
5110ad053e | |
|
|
2be16d2242 | |
|
|
7053f5a537 | |
|
|
a905e14cc4 | |
|
|
e89036f716 | |
|
|
5880fa5321 | |
|
|
38503e7c33 | |
|
|
5429d1e3e2 | |
|
|
b6c3781cdc | |
|
|
12446d7d50 | |
|
|
d231e94535 | |
|
|
e3cdf0faae | |
|
|
a9f4d4941a | |
|
|
3d0846051f | |
|
|
6e5419c561 | |
|
|
1041a4cc9b | |
|
|
a09b39fb57 | |
|
|
093a72da05 | |
|
|
e0905ac794 | |
|
|
b34f3f7208 | |
|
|
51292a9793 | |
|
|
1cd0fb5dab | |
|
|
5c6a766ff6 | |
|
|
6b1fd76b7d | |
|
|
581846992d | |
|
|
86e5ec8ba5 | |
|
|
5a0bd8d1fa | |
|
|
28cdbe4f22 | |
|
|
a4126d025b | |
|
|
b4345d151a | |
|
|
af77332871 | |
|
|
c33ea2757c | |
|
|
6753507826 | |
|
|
7884909253 | |
|
|
812dc7cf2f | |
|
|
81027f2211 |
|
|
@ -19,6 +19,7 @@ jobs:
|
||||||
- build-nix
|
- build-nix
|
||||||
- build-macos
|
- build-macos
|
||||||
- build-macos-matrix
|
- build-macos-matrix
|
||||||
|
- build-snap
|
||||||
- build-windows
|
- build-windows
|
||||||
- test
|
- test
|
||||||
- test-gtk
|
- test-gtk
|
||||||
|
|
@ -118,7 +119,41 @@ jobs:
|
||||||
run: |
|
run: |
|
||||||
nix develop -c \
|
nix develop -c \
|
||||||
zig build \
|
zig build \
|
||||||
-Dflatpak=true
|
-Dflatpak
|
||||||
|
|
||||||
|
build-snap:
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
runs-on: namespace-profile-ghostty-sm
|
||||||
|
needs: test
|
||||||
|
env:
|
||||||
|
ZIG_LOCAL_CACHE_DIR: /zig/local-cache
|
||||||
|
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
|
|
||||||
|
- name: Setup Cache
|
||||||
|
uses: namespacelabs/nscloud-cache-action@a289cf5d2fcd6874376aa92f0ef7f99dc923592a # v1.2.17
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
/nix
|
||||||
|
/zig
|
||||||
|
|
||||||
|
# Install Nix and use that to run our tests so our environment matches exactly.
|
||||||
|
- uses: cachix/install-nix-action@7be5dee1421f63d07e71ce6e0a9f8a4b07c2a487 # v31.6.1
|
||||||
|
with:
|
||||||
|
nix_path: nixpkgs=channel:nixos-unstable
|
||||||
|
- uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16
|
||||||
|
with:
|
||||||
|
name: ghostty
|
||||||
|
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
||||||
|
|
||||||
|
- name: Build with Snap
|
||||||
|
run: |
|
||||||
|
nix develop -c \
|
||||||
|
zig build \
|
||||||
|
-Dsnap
|
||||||
|
|
||||||
build-linux:
|
build-linux:
|
||||||
strategy:
|
strategy:
|
||||||
|
|
@ -275,7 +310,7 @@ jobs:
|
||||||
trigger-snap:
|
trigger-snap:
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
runs-on: namespace-profile-ghostty-xsm
|
runs-on: namespace-profile-ghostty-xsm
|
||||||
needs: build-dist
|
needs: [build-dist, build-snap]
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
|
|
|
||||||
|
|
@ -184,6 +184,8 @@
|
||||||
/po/ko_KR.UTF-8.po @ghostty-org/ko_KR
|
/po/ko_KR.UTF-8.po @ghostty-org/ko_KR
|
||||||
/po/he_IL.UTF-8.po @ghostty-org/he_IL
|
/po/he_IL.UTF-8.po @ghostty-org/he_IL
|
||||||
/po/it_IT.UTF-8.po @ghostty-org/it_IT
|
/po/it_IT.UTF-8.po @ghostty-org/it_IT
|
||||||
|
/po/zh_TW.UTF-8.po @ghostty-org/zh_TW
|
||||||
|
/po/hr_HR.UTF-8.po @ghostty-org/hr_HR
|
||||||
|
|
||||||
# Packaging - Snap
|
# Packaging - Snap
|
||||||
/snap/ @ghostty-org/snap
|
/snap/ @ghostty-org/snap
|
||||||
|
|
|
||||||
18
build.zig
18
build.zig
|
|
@ -8,7 +8,13 @@ comptime {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn build(b: *std.Build) !void {
|
pub fn build(b: *std.Build) !void {
|
||||||
// This defines all the available build options (e.g. `-D`).
|
// Works around a Zig but still present in 0.15.1. Remove when fixed.
|
||||||
|
// https://github.com/ghostty-org/ghostty/issues/8924
|
||||||
|
try limitCoresForZigBug();
|
||||||
|
|
||||||
|
// This defines all the available build options (e.g. `-D`). If you
|
||||||
|
// want to know what options are available, you can run `--help` or
|
||||||
|
// you can read `src/build/Config.zig`.
|
||||||
const config = try buildpkg.Config.init(b);
|
const config = try buildpkg.Config.init(b);
|
||||||
const test_filter = b.option(
|
const test_filter = b.option(
|
||||||
[]const u8,
|
[]const u8,
|
||||||
|
|
@ -258,3 +264,13 @@ pub fn build(b: *std.Build) !void {
|
||||||
try translations_step.addError("cannot update translations when i18n is disabled", .{});
|
try translations_step.addError("cannot update translations when i18n is disabled", .{});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WARNING: Remove this when https://github.com/ghostty-org/ghostty/issues/8924 is resolved!
|
||||||
|
// Limit ourselves to 32 cpus on Linux because of an upstream Zig bug.
|
||||||
|
fn limitCoresForZigBug() !void {
|
||||||
|
if (comptime builtin.os.tag != .linux) return;
|
||||||
|
const pid = std.os.linux.getpid();
|
||||||
|
var set: std.bit_set.ArrayBitSet(usize, std.os.linux.CPU_SETSIZE * 8) = .initEmpty();
|
||||||
|
for (0..32) |cpu| set.set(cpu);
|
||||||
|
try std.os.linux.sched_setaffinity(pid, &set.masks);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
.{
|
.{
|
||||||
.name = .ghostty,
|
.name = .ghostty,
|
||||||
.version = "1.2.0",
|
.version = "1.2.3",
|
||||||
.paths = .{""},
|
.paths = .{""},
|
||||||
.fingerprint = 0x64407a2a0b4147e5,
|
.fingerprint = 0x64407a2a0b4147e5,
|
||||||
|
.minimum_zig_version = "0.14.1",
|
||||||
.dependencies = .{
|
.dependencies = .{
|
||||||
// Zig libs
|
// Zig libs
|
||||||
|
|
||||||
|
|
@ -112,8 +113,8 @@
|
||||||
// Other
|
// Other
|
||||||
.apple_sdk = .{ .path = "./pkg/apple-sdk" },
|
.apple_sdk = .{ .path = "./pkg/apple-sdk" },
|
||||||
.iterm2_themes = .{
|
.iterm2_themes = .{
|
||||||
.url = "https://deps.files.ghostty.org/ghostty-themes-20250915-162204-b1fe546.tgz",
|
.url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251002-142451-4a5043e/ghostty-themes.tgz",
|
||||||
.hash = "N-V-__8AANodAwDnyHwhlOv5cVRn2rx_dTvija-wy5YtTw1B",
|
.hash = "N-V-__8AALIsAwDyo88G5mGJGN2lSVmmFMx4YePfUvp_2o3Y",
|
||||||
.lazy = true,
|
.lazy = true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -49,10 +49,10 @@
|
||||||
"url": "https://deps.files.ghostty.org/imgui-1220bc6b9daceaf7c8c60f3c3998058045ba0c5c5f48ae255ff97776d9cd8bfc6402.tar.gz",
|
"url": "https://deps.files.ghostty.org/imgui-1220bc6b9daceaf7c8c60f3c3998058045ba0c5c5f48ae255ff97776d9cd8bfc6402.tar.gz",
|
||||||
"hash": "sha256-oF/QHgTPEat4Hig4fGIdLkIPHmBEyOJ6JeYD6pnveGA="
|
"hash": "sha256-oF/QHgTPEat4Hig4fGIdLkIPHmBEyOJ6JeYD6pnveGA="
|
||||||
},
|
},
|
||||||
"N-V-__8AANodAwDnyHwhlOv5cVRn2rx_dTvija-wy5YtTw1B": {
|
"N-V-__8AALIsAwDyo88G5mGJGN2lSVmmFMx4YePfUvp_2o3Y": {
|
||||||
"name": "iterm2_themes",
|
"name": "iterm2_themes",
|
||||||
"url": "https://deps.files.ghostty.org/ghostty-themes-20250915-162204-b1fe546.tgz",
|
"url": "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251002-142451-4a5043e/ghostty-themes.tgz",
|
||||||
"hash": "sha256-6rKNFpaUvSbvNZ0/+u0h4I/RRaV5V7xIPQ9y7eNVbCA="
|
"hash": "sha256-GsEWVt4wMzp6+7N5I+QVuhCVJ70cFrdADwUds59AKnw="
|
||||||
},
|
},
|
||||||
"N-V-__8AAIC5lwAVPJJzxnCAahSvZTIlG-HhtOvnM1uh-66x": {
|
"N-V-__8AAIC5lwAVPJJzxnCAahSvZTIlG-HhtOvnM1uh-66x": {
|
||||||
"name": "jetbrains_mono",
|
"name": "jetbrains_mono",
|
||||||
|
|
|
||||||
|
|
@ -163,11 +163,11 @@ in
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
name = "N-V-__8AANodAwDnyHwhlOv5cVRn2rx_dTvija-wy5YtTw1B";
|
name = "N-V-__8AALIsAwDyo88G5mGJGN2lSVmmFMx4YePfUvp_2o3Y";
|
||||||
path = fetchZigArtifact {
|
path = fetchZigArtifact {
|
||||||
name = "iterm2_themes";
|
name = "iterm2_themes";
|
||||||
url = "https://deps.files.ghostty.org/ghostty-themes-20250915-162204-b1fe546.tgz";
|
url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251002-142451-4a5043e/ghostty-themes.tgz";
|
||||||
hash = "sha256-6rKNFpaUvSbvNZ0/+u0h4I/RRaV5V7xIPQ9y7eNVbCA=";
|
hash = "sha256-GsEWVt4wMzp6+7N5I+QVuhCVJ70cFrdADwUds59AKnw=";
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ https://deps.files.ghostty.org/breakpad-b99f444ba5f6b98cac261cbb391d8766b34a5918
|
||||||
https://deps.files.ghostty.org/fontconfig-2.14.2.tar.gz
|
https://deps.files.ghostty.org/fontconfig-2.14.2.tar.gz
|
||||||
https://deps.files.ghostty.org/freetype-1220b81f6ecfb3fd222f76cf9106fecfa6554ab07ec7fdc4124b9bb063ae2adf969d.tar.gz
|
https://deps.files.ghostty.org/freetype-1220b81f6ecfb3fd222f76cf9106fecfa6554ab07ec7fdc4124b9bb063ae2adf969d.tar.gz
|
||||||
https://deps.files.ghostty.org/gettext-0.24.tar.gz
|
https://deps.files.ghostty.org/gettext-0.24.tar.gz
|
||||||
https://deps.files.ghostty.org/ghostty-themes-20250915-162204-b1fe546.tgz
|
|
||||||
https://deps.files.ghostty.org/glslang-12201278a1a05c0ce0b6eb6026c65cd3e9247aa041b1c260324bf29cee559dd23ba1.tar.gz
|
https://deps.files.ghostty.org/glslang-12201278a1a05c0ce0b6eb6026c65cd3e9247aa041b1c260324bf29cee559dd23ba1.tar.gz
|
||||||
https://deps.files.ghostty.org/gtk4-layer-shell-1.1.0.tar.gz
|
https://deps.files.ghostty.org/gtk4-layer-shell-1.1.0.tar.gz
|
||||||
https://deps.files.ghostty.org/harfbuzz-11.0.0.tar.xz
|
https://deps.files.ghostty.org/harfbuzz-11.0.0.tar.xz
|
||||||
|
|
@ -29,6 +28,7 @@ https://deps.files.ghostty.org/zig_js-12205a66d423259567764fa0fc60c82be35365c21a
|
||||||
https://deps.files.ghostty.org/ziglyph-b89d43d1e3fb01b6074bc1f7fc980324b04d26a5.tar.gz
|
https://deps.files.ghostty.org/ziglyph-b89d43d1e3fb01b6074bc1f7fc980324b04d26a5.tar.gz
|
||||||
https://deps.files.ghostty.org/zlib-1220fed0c74e1019b3ee29edae2051788b080cd96e90d56836eea857b0b966742efb.tar.gz
|
https://deps.files.ghostty.org/zlib-1220fed0c74e1019b3ee29edae2051788b080cd96e90d56836eea857b0b966742efb.tar.gz
|
||||||
https://github.com/jcollie/ghostty-gobject/releases/download/0.15.1-2025-09-04-48-1/ghostty-gobject-0.15.1-2025-09-04-48-1.tar.zst
|
https://github.com/jcollie/ghostty-gobject/releases/download/0.15.1-2025-09-04-48-1/ghostty-gobject-0.15.1-2025-09-04-48-1.tar.zst
|
||||||
|
https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251002-142451-4a5043e/ghostty-themes.tgz
|
||||||
https://github.com/mitchellh/libxev/archive/7f803181b158a10fec8619f793e3b4df515566cb.tar.gz
|
https://github.com/mitchellh/libxev/archive/7f803181b158a10fec8619f793e3b4df515566cb.tar.gz
|
||||||
https://github.com/mitchellh/zig-objc/archive/c9e917a4e15a983b672ca779c7985d738a2d517c.tar.gz
|
https://github.com/mitchellh/zig-objc/archive/c9e917a4e15a983b672ca779c7985d738a2d517c.tar.gz
|
||||||
https://github.com/natecraddock/zf/archive/7aacbe6d155d64d15937ca95ca6c014905eb531f.tar.gz
|
https://github.com/natecraddock/zf/archive/7aacbe6d155d64d15937ca95ca6c014905eb531f.tar.gz
|
||||||
|
|
|
||||||
|
|
@ -52,8 +52,8 @@
|
||||||
|
|
||||||
<releases>
|
<releases>
|
||||||
<!-- TODO: Generate this automatically -->
|
<!-- TODO: Generate this automatically -->
|
||||||
<release version="1.0.1" date="2024-12-31">
|
<release version="1.2.3" date="2025-10-08">
|
||||||
<url type="details">https://ghostty.org/docs/install/release-notes/1-0-1</url>
|
<url type="details">https://ghostty.org/docs/install/release-notes/1-2-3</url>
|
||||||
</release>
|
</release>
|
||||||
</releases>
|
</releases>
|
||||||
</component>
|
</component>
|
||||||
|
|
|
||||||
|
|
@ -61,9 +61,9 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
"url": "https://deps.files.ghostty.org/ghostty-themes-20250915-162204-b1fe546.tgz",
|
"url": "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251002-142451-4a5043e/ghostty-themes.tgz",
|
||||||
"dest": "vendor/p/N-V-__8AANodAwDnyHwhlOv5cVRn2rx_dTvija-wy5YtTw1B",
|
"dest": "vendor/p/N-V-__8AALIsAwDyo88G5mGJGN2lSVmmFMx4YePfUvp_2o3Y",
|
||||||
"sha256": "eab28d169694bd26ef359d3ffaed21e08fd145a57957bc483d0f72ede3556c20"
|
"sha256": "1ac11656de30333a7afbb37923e415ba109527bd1c16b7400f051db39f402a7c"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "archive",
|
"type": "archive",
|
||||||
|
|
|
||||||
|
|
@ -353,6 +353,7 @@ typedef struct {
|
||||||
typedef struct {
|
typedef struct {
|
||||||
const char* ptr;
|
const char* ptr;
|
||||||
uintptr_t len;
|
uintptr_t len;
|
||||||
|
bool sentinel;
|
||||||
} ghostty_string_s;
|
} ghostty_string_s;
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,8 @@
|
||||||
A53D0C952B53B4D800305CE6 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5B30538299BEAAB0047F10C /* Assets.xcassets */; };
|
A53D0C952B53B4D800305CE6 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5B30538299BEAAB0047F10C /* Assets.xcassets */; };
|
||||||
A53D0C9B2B543F3B00305CE6 /* Ghostty.App.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */; };
|
A53D0C9B2B543F3B00305CE6 /* Ghostty.App.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */; };
|
||||||
A53D0C9C2B543F7B00305CE6 /* Package.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55B7BB729B6F53A0055DE60 /* Package.swift */; };
|
A53D0C9C2B543F7B00305CE6 /* Package.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55B7BB729B6F53A0055DE60 /* Package.swift */; };
|
||||||
|
A53F889C2EAA930F00F1C56B /* QuickTerminalScreenStateCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53F889B2EAA930F00F1C56B /* QuickTerminalScreenStateCache.swift */; };
|
||||||
|
A53F889E2EAA932B00F1C56B /* UUID+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53F889D2EAA932B00F1C56B /* UUID+Extension.swift */; };
|
||||||
A546F1142D7B68D7003B11A0 /* locale in Resources */ = {isa = PBXBuildFile; fileRef = A546F1132D7B68D7003B11A0 /* locale */; };
|
A546F1142D7B68D7003B11A0 /* locale in Resources */ = {isa = PBXBuildFile; fileRef = A546F1132D7B68D7003B11A0 /* locale */; };
|
||||||
A54B0CE92D0CECD100CBEFF8 /* ColorizedGhosttyIconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54B0CE82D0CECD100CBEFF8 /* ColorizedGhosttyIconView.swift */; };
|
A54B0CE92D0CECD100CBEFF8 /* ColorizedGhosttyIconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54B0CE82D0CECD100CBEFF8 /* ColorizedGhosttyIconView.swift */; };
|
||||||
A54B0CEB2D0CFB4C00CBEFF8 /* NSImage+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54B0CEA2D0CFB4A00CBEFF8 /* NSImage+Extension.swift */; };
|
A54B0CEB2D0CFB4C00CBEFF8 /* NSImage+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54B0CEA2D0CFB4A00CBEFF8 /* NSImage+Extension.swift */; };
|
||||||
|
|
@ -143,6 +145,8 @@
|
||||||
A5E408432E047D0B0035FEAC /* CommandPaletteIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408422E047D060035FEAC /* CommandPaletteIntent.swift */; };
|
A5E408432E047D0B0035FEAC /* CommandPaletteIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408422E047D060035FEAC /* CommandPaletteIntent.swift */; };
|
||||||
A5E408452E0483FD0035FEAC /* KeybindIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408442E0483F80035FEAC /* KeybindIntent.swift */; };
|
A5E408452E0483FD0035FEAC /* KeybindIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408442E0483F80035FEAC /* KeybindIntent.swift */; };
|
||||||
A5E408472E04852B0035FEAC /* InputIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408462E0485270035FEAC /* InputIntent.swift */; };
|
A5E408472E04852B0035FEAC /* InputIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408462E0485270035FEAC /* InputIntent.swift */; };
|
||||||
|
A5F9A1F22E7C7301005AFACE /* SurfaceProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5F9A1F12E7C7301005AFACE /* SurfaceProgressBar.swift */; };
|
||||||
|
A5FB3A882E942A1B00A919E5 /* SurfaceProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5F9A1F12E7C7301005AFACE /* SurfaceProgressBar.swift */; };
|
||||||
A5FEB3002ABB69450068369E /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FEB2FF2ABB69450068369E /* main.swift */; };
|
A5FEB3002ABB69450068369E /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FEB2FF2ABB69450068369E /* main.swift */; };
|
||||||
AEE8B3452B9AA39600260C5E /* NSPasteboard+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */; };
|
AEE8B3452B9AA39600260C5E /* NSPasteboard+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */; };
|
||||||
C159E81D2B66A06B00FDFE9C /* OSColor+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */; };
|
C159E81D2B66A06B00FDFE9C /* OSColor+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */; };
|
||||||
|
|
@ -205,6 +209,8 @@
|
||||||
A53A6C022CCC1B7D00943E98 /* Ghostty.Action.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Action.swift; sourceTree = "<group>"; };
|
A53A6C022CCC1B7D00943E98 /* Ghostty.Action.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Action.swift; sourceTree = "<group>"; };
|
||||||
A53D0C932B53B43700305CE6 /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = "<group>"; };
|
A53D0C932B53B43700305CE6 /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = "<group>"; };
|
||||||
A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.App.swift; sourceTree = "<group>"; };
|
A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.App.swift; sourceTree = "<group>"; };
|
||||||
|
A53F889B2EAA930F00F1C56B /* QuickTerminalScreenStateCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickTerminalScreenStateCache.swift; sourceTree = "<group>"; };
|
||||||
|
A53F889D2EAA932B00F1C56B /* UUID+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UUID+Extension.swift"; sourceTree = "<group>"; };
|
||||||
A546F1132D7B68D7003B11A0 /* locale */ = {isa = PBXFileReference; lastKnownFileType = folder; name = locale; path = "../zig-out/share/locale"; sourceTree = "<group>"; };
|
A546F1132D7B68D7003B11A0 /* locale */ = {isa = PBXFileReference; lastKnownFileType = folder; name = locale; path = "../zig-out/share/locale"; sourceTree = "<group>"; };
|
||||||
A54B0CE82D0CECD100CBEFF8 /* ColorizedGhosttyIconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorizedGhosttyIconView.swift; sourceTree = "<group>"; };
|
A54B0CE82D0CECD100CBEFF8 /* ColorizedGhosttyIconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorizedGhosttyIconView.swift; sourceTree = "<group>"; };
|
||||||
A54B0CEA2D0CFB4A00CBEFF8 /* NSImage+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSImage+Extension.swift"; sourceTree = "<group>"; };
|
A54B0CEA2D0CFB4A00CBEFF8 /* NSImage+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSImage+Extension.swift"; sourceTree = "<group>"; };
|
||||||
|
|
@ -293,6 +299,7 @@
|
||||||
A5E408422E047D060035FEAC /* CommandPaletteIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteIntent.swift; sourceTree = "<group>"; };
|
A5E408422E047D060035FEAC /* CommandPaletteIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteIntent.swift; sourceTree = "<group>"; };
|
||||||
A5E408442E0483F80035FEAC /* KeybindIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeybindIntent.swift; sourceTree = "<group>"; };
|
A5E408442E0483F80035FEAC /* KeybindIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeybindIntent.swift; sourceTree = "<group>"; };
|
||||||
A5E408462E0485270035FEAC /* InputIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputIntent.swift; sourceTree = "<group>"; };
|
A5E408462E0485270035FEAC /* InputIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputIntent.swift; sourceTree = "<group>"; };
|
||||||
|
A5F9A1F12E7C7301005AFACE /* SurfaceProgressBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceProgressBar.swift; sourceTree = "<group>"; };
|
||||||
A5FEB2FF2ABB69450068369E /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = "<group>"; };
|
A5FEB2FF2ABB69450068369E /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = "<group>"; };
|
||||||
AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSPasteboard+Extension.swift"; sourceTree = "<group>"; };
|
AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSPasteboard+Extension.swift"; sourceTree = "<group>"; };
|
||||||
C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OSColor+Extension.swift"; sourceTree = "<group>"; };
|
C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OSColor+Extension.swift"; sourceTree = "<group>"; };
|
||||||
|
|
@ -492,6 +499,7 @@
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
A55B7BB729B6F53A0055DE60 /* Package.swift */,
|
A55B7BB729B6F53A0055DE60 /* Package.swift */,
|
||||||
|
A5F9A1F12E7C7301005AFACE /* SurfaceProgressBar.swift */,
|
||||||
A55B7BBB29B6FC330055DE60 /* SurfaceView.swift */,
|
A55B7BBB29B6FC330055DE60 /* SurfaceView.swift */,
|
||||||
A5333E212B5A2128008AEFF7 /* SurfaceView_AppKit.swift */,
|
A5333E212B5A2128008AEFF7 /* SurfaceView_AppKit.swift */,
|
||||||
A5333E152B59DE8E008AEFF7 /* SurfaceView_UIKit.swift */,
|
A5333E152B59DE8E008AEFF7 /* SurfaceView_UIKit.swift */,
|
||||||
|
|
@ -561,6 +569,7 @@
|
||||||
A505D21E2E1B6DDC0018808F /* NSWorkspace+Extension.swift */,
|
A505D21E2E1B6DDC0018808F /* NSWorkspace+Extension.swift */,
|
||||||
A5985CD62C320C4500C57AD3 /* String+Extension.swift */,
|
A5985CD62C320C4500C57AD3 /* String+Extension.swift */,
|
||||||
A58636722DF4813000E04A10 /* UndoManager+Extension.swift */,
|
A58636722DF4813000E04A10 /* UndoManager+Extension.swift */,
|
||||||
|
A53F889D2EAA932B00F1C56B /* UUID+Extension.swift */,
|
||||||
A5CC36142C9CDA03004D6760 /* View+Extension.swift */,
|
A5CC36142C9CDA03004D6760 /* View+Extension.swift */,
|
||||||
);
|
);
|
||||||
path = Extensions;
|
path = Extensions;
|
||||||
|
|
@ -640,6 +649,7 @@
|
||||||
CFBB5FE92D231E5000FD62EE /* QuickTerminalSpaceBehavior.swift */,
|
CFBB5FE92D231E5000FD62EE /* QuickTerminalSpaceBehavior.swift */,
|
||||||
A5CBD0632CA122E70017A1AE /* QuickTerminalPosition.swift */,
|
A5CBD0632CA122E70017A1AE /* QuickTerminalPosition.swift */,
|
||||||
A52FFF562CA90481000C6A5B /* QuickTerminalScreen.swift */,
|
A52FFF562CA90481000C6A5B /* QuickTerminalScreen.swift */,
|
||||||
|
A53F889B2EAA930F00F1C56B /* QuickTerminalScreenStateCache.swift */,
|
||||||
A5BB78B82DF9D8CE009AC3FA /* QuickTerminalSize.swift */,
|
A5BB78B82DF9D8CE009AC3FA /* QuickTerminalSize.swift */,
|
||||||
A5CBD05F2CA0C9080017A1AE /* QuickTerminalWindow.swift */,
|
A5CBD05F2CA0C9080017A1AE /* QuickTerminalWindow.swift */,
|
||||||
);
|
);
|
||||||
|
|
@ -892,6 +902,7 @@
|
||||||
A5CC36152C9CDA06004D6760 /* View+Extension.swift in Sources */,
|
A5CC36152C9CDA06004D6760 /* View+Extension.swift in Sources */,
|
||||||
A56D58892ACDE6CA00508D2C /* ServiceProvider.swift in Sources */,
|
A56D58892ACDE6CA00508D2C /* ServiceProvider.swift in Sources */,
|
||||||
A5CBD0602CA0C90A0017A1AE /* QuickTerminalWindow.swift in Sources */,
|
A5CBD0602CA0C90A0017A1AE /* QuickTerminalWindow.swift in Sources */,
|
||||||
|
A5F9A1F22E7C7301005AFACE /* SurfaceProgressBar.swift in Sources */,
|
||||||
A505D21F2E1B6DE00018808F /* NSWorkspace+Extension.swift in Sources */,
|
A505D21F2E1B6DE00018808F /* NSWorkspace+Extension.swift in Sources */,
|
||||||
A5CBD05E2CA0C5EC0017A1AE /* QuickTerminalController.swift in Sources */,
|
A5CBD05E2CA0C5EC0017A1AE /* QuickTerminalController.swift in Sources */,
|
||||||
A5CF66D72D29DDB500139794 /* Ghostty.Event.swift in Sources */,
|
A5CF66D72D29DDB500139794 /* Ghostty.Event.swift in Sources */,
|
||||||
|
|
@ -903,6 +914,7 @@
|
||||||
A51BFC222B2FB6B400E92F16 /* AboutView.swift in Sources */,
|
A51BFC222B2FB6B400E92F16 /* AboutView.swift in Sources */,
|
||||||
A5278A9B2AA05B2600CD3039 /* Ghostty.Input.swift in Sources */,
|
A5278A9B2AA05B2600CD3039 /* Ghostty.Input.swift in Sources */,
|
||||||
A53A29812DB44A6100B6E02C /* KeyboardShortcut+Extension.swift in Sources */,
|
A53A29812DB44A6100B6E02C /* KeyboardShortcut+Extension.swift in Sources */,
|
||||||
|
A53F889C2EAA930F00F1C56B /* QuickTerminalScreenStateCache.swift in Sources */,
|
||||||
A50297352DFA0F3400B4E924 /* Double+Extension.swift in Sources */,
|
A50297352DFA0F3400B4E924 /* Double+Extension.swift in Sources */,
|
||||||
A5CBD0562C9E65B80017A1AE /* DraggableWindowView.swift in Sources */,
|
A5CBD0562C9E65B80017A1AE /* DraggableWindowView.swift in Sources */,
|
||||||
A51194112E05A483007258CC /* QuickTerminalIntent.swift in Sources */,
|
A51194112E05A483007258CC /* QuickTerminalIntent.swift in Sources */,
|
||||||
|
|
@ -947,6 +959,7 @@
|
||||||
A5BB78B92DF9D8CE009AC3FA /* QuickTerminalSize.swift in Sources */,
|
A5BB78B92DF9D8CE009AC3FA /* QuickTerminalSize.swift in Sources */,
|
||||||
A51B78472AF4B58B00F3EDB9 /* TitlebarTabsVenturaTerminalWindow.swift in Sources */,
|
A51B78472AF4B58B00F3EDB9 /* TitlebarTabsVenturaTerminalWindow.swift in Sources */,
|
||||||
A5BB78B92DF9D8CE009AC3FA /* QuickTerminalSize.swift in Sources */,
|
A5BB78B92DF9D8CE009AC3FA /* QuickTerminalSize.swift in Sources */,
|
||||||
|
A53F889E2EAA932B00F1C56B /* UUID+Extension.swift in Sources */,
|
||||||
A57D79272C9C879B001D522E /* SecureInput.swift in Sources */,
|
A57D79272C9C879B001D522E /* SecureInput.swift in Sources */,
|
||||||
A5CEAFDC29B8009000646FDA /* SplitView.swift in Sources */,
|
A5CEAFDC29B8009000646FDA /* SplitView.swift in Sources */,
|
||||||
A5593FE12DF8D74000B47B10 /* HiddenTitlebarTerminalWindow.swift in Sources */,
|
A5593FE12DF8D74000B47B10 /* HiddenTitlebarTerminalWindow.swift in Sources */,
|
||||||
|
|
@ -986,6 +999,7 @@
|
||||||
A53D0C942B53B43700305CE6 /* iOSApp.swift in Sources */,
|
A53D0C942B53B43700305CE6 /* iOSApp.swift in Sources */,
|
||||||
A514C8D72B54A16400493A16 /* Ghostty.Config.swift in Sources */,
|
A514C8D72B54A16400493A16 /* Ghostty.Config.swift in Sources */,
|
||||||
A5333E232B5A219A008AEFF7 /* SurfaceView.swift in Sources */,
|
A5333E232B5A219A008AEFF7 /* SurfaceView.swift in Sources */,
|
||||||
|
A5FB3A882E942A1B00A919E5 /* SurfaceProgressBar.swift in Sources */,
|
||||||
A5333E202B5A2111008AEFF7 /* SurfaceView_UIKit.swift in Sources */,
|
A5333E202B5A2111008AEFF7 /* SurfaceView_UIKit.swift in Sources */,
|
||||||
A5333E1D2B5A1CE3008AEFF7 /* CrossKit.swift in Sources */,
|
A5333E1D2B5A1CE3008AEFF7 /* CrossKit.swift in Sources */,
|
||||||
A5D689BE2E654D98002E2346 /* Ghostty.Action.swift in Sources */,
|
A5D689BE2E654D98002E2346 /* Ghostty.Action.swift in Sources */,
|
||||||
|
|
|
||||||
|
|
@ -471,7 +471,12 @@ class AppDelegate: NSObject,
|
||||||
}
|
}
|
||||||
|
|
||||||
switch ghostty.config.macosDockDropBehavior {
|
switch ghostty.config.macosDockDropBehavior {
|
||||||
case .new_tab: _ = TerminalController.newTab(ghostty, withBaseConfig: config)
|
case .new_tab:
|
||||||
|
_ = TerminalController.newTab(
|
||||||
|
ghostty,
|
||||||
|
from: TerminalController.preferredParent?.window,
|
||||||
|
withBaseConfig: config
|
||||||
|
)
|
||||||
case .new_window: _ = TerminalController.newWindow(ghostty, withBaseConfig: config)
|
case .new_window: _ = TerminalController.newWindow(ghostty, withBaseConfig: config)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -860,7 +865,12 @@ class AppDelegate: NSObject,
|
||||||
} else {
|
} else {
|
||||||
GlobalEventTap.shared.disable()
|
GlobalEventTap.shared.disable()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sync the appearance of our app with the theme specified in the config.
|
||||||
|
private func syncAppearance(config: Ghostty.Config) {
|
||||||
|
NSApplication.shared.appearance = .init(ghosttyConfig: config)
|
||||||
|
|
||||||
switch (config.macosIcon) {
|
switch (config.macosIcon) {
|
||||||
case .official:
|
case .official:
|
||||||
self.appIcon = nil
|
self.appIcon = nil
|
||||||
|
|
@ -909,11 +919,6 @@ class AppDelegate: NSObject,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sync the appearance of our app with the theme specified in the config.
|
|
||||||
private func syncAppearance(config: Ghostty.Config) {
|
|
||||||
NSApplication.shared.appearance = .init(ghosttyConfig: config)
|
|
||||||
}
|
|
||||||
|
|
||||||
//MARK: - Restorable State
|
//MARK: - Restorable State
|
||||||
|
|
||||||
/// We support NSSecureCoding for restorable state. Required as of macOS Sonoma (14) but a good idea anyways.
|
/// We support NSSecureCoding for restorable state. Required as of macOS Sonoma (14) but a good idea anyways.
|
||||||
|
|
@ -1012,7 +1017,10 @@ class AppDelegate: NSObject,
|
||||||
}
|
}
|
||||||
|
|
||||||
@IBAction func newTab(_ sender: Any?) {
|
@IBAction func newTab(_ sender: Any?) {
|
||||||
_ = TerminalController.newTab(ghostty)
|
_ = TerminalController.newTab(
|
||||||
|
ghostty,
|
||||||
|
from: TerminalController.preferredParent?.window
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@IBAction func closeAllWindows(_ sender: Any?) {
|
@IBAction func closeAllWindows(_ sender: Any?) {
|
||||||
|
|
|
||||||
|
|
@ -43,11 +43,13 @@ struct NewTerminalIntent: AppIntent {
|
||||||
)
|
)
|
||||||
var parent: TerminalEntity?
|
var parent: TerminalEntity?
|
||||||
|
|
||||||
|
// Performing in the background can avoid opening multiple windows at the same time
|
||||||
|
// using `foreground` will cause `perform` and `AppDelegate.applicationDidBecomeActive(_:)`/`AppDelegate.applicationShouldHandleReopen(_:hasVisibleWindows:)` running at the 'same' time
|
||||||
@available(macOS 26.0, *)
|
@available(macOS 26.0, *)
|
||||||
static var supportedModes: IntentModes = .foreground(.immediate)
|
static var supportedModes: IntentModes = .background
|
||||||
|
|
||||||
@available(macOS, obsoleted: 26.0, message: "Replaced by supportedModes")
|
@available(macOS, obsoleted: 26.0, message: "Replaced by supportedModes")
|
||||||
static var openAppWhenRun = true
|
static var openAppWhenRun = false
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func perform() async throws -> some IntentResult & ReturnsValue<TerminalEntity?> {
|
func perform() async throws -> some IntentResult & ReturnsValue<TerminalEntity?> {
|
||||||
|
|
@ -96,6 +98,11 @@ struct NewTerminalIntent: AppIntent {
|
||||||
parent = nil
|
parent = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
defer {
|
||||||
|
if !NSApp.isActive {
|
||||||
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
switch location {
|
switch location {
|
||||||
case .window:
|
case .window:
|
||||||
let newController = TerminalController.newWindow(
|
let newController = TerminalController.newWindow(
|
||||||
|
|
@ -123,7 +130,8 @@ struct NewTerminalIntent: AppIntent {
|
||||||
|
|
||||||
if let view = controller.newSplit(
|
if let view = controller.newSplit(
|
||||||
at: parent,
|
at: parent,
|
||||||
direction: location.splitDirection!
|
direction: location.splitDirection!,
|
||||||
|
baseConfig: config
|
||||||
) {
|
) {
|
||||||
return .result(value: TerminalEntity(view))
|
return .result(value: TerminalEntity(view))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,19 +45,15 @@ struct ClipboardConfirmationView: View {
|
||||||
.font(.system(size: 42))
|
.font(.system(size: 42))
|
||||||
.padding()
|
.padding()
|
||||||
.frame(alignment: .center)
|
.frame(alignment: .center)
|
||||||
|
|
||||||
Text(request.text())
|
Text(request.text())
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
|
|
||||||
ScrollView {
|
TextEditor(text: .constant(contents))
|
||||||
Text(contents)
|
.font(.system(.body, design: .monospaced))
|
||||||
.textSelection(.enabled)
|
|
||||||
.font(.system(.body, design: .monospaced))
|
|
||||||
.padding(.all, 4)
|
|
||||||
}
|
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
Spacer()
|
Spacer()
|
||||||
Button(Action.text(.cancel, request)) { onCancel() }
|
Button(Action.text(.cancel, request)) { onCancel() }
|
||||||
|
|
|
||||||
|
|
@ -21,13 +21,8 @@ class QuickTerminalController: BaseTerminalController {
|
||||||
// The active space when the quick terminal was last shown.
|
// The active space when the quick terminal was last shown.
|
||||||
private var previousActiveSpace: CGSSpace? = nil
|
private var previousActiveSpace: CGSSpace? = nil
|
||||||
|
|
||||||
/// The window frame saved when the quick terminal's surface tree becomes empty.
|
/// Cache for per-screen window state.
|
||||||
///
|
private let screenStateCache = QuickTerminalScreenStateCache()
|
||||||
/// This preserves the user's window size and position when all terminal surfaces
|
|
||||||
/// are closed (e.g., via the `exit` command). When a new surface is created,
|
|
||||||
/// the window will be restored to this frame, preventing SwiftUI from resetting
|
|
||||||
/// the window to its default minimum size.
|
|
||||||
private var lastClosedFrame: NSRect? = nil
|
|
||||||
|
|
||||||
/// Non-nil if we have hidden dock state.
|
/// Non-nil if we have hidden dock state.
|
||||||
private var hiddenDock: HiddenDock? = nil
|
private var hiddenDock: HiddenDock? = nil
|
||||||
|
|
@ -359,16 +354,15 @@ class QuickTerminalController: BaseTerminalController {
|
||||||
private func animateWindowIn(window: NSWindow, from position: QuickTerminalPosition) {
|
private func animateWindowIn(window: NSWindow, from position: QuickTerminalPosition) {
|
||||||
guard let screen = derivedConfig.quickTerminalScreen.screen else { return }
|
guard let screen = derivedConfig.quickTerminalScreen.screen else { return }
|
||||||
|
|
||||||
// Grab our last closed frame to use, and clear our state since we're animating in.
|
// Grab our last closed frame to use from the cache.
|
||||||
let lastClosedFrame = self.lastClosedFrame
|
let closedFrame = screenStateCache.frame(for: screen)
|
||||||
self.lastClosedFrame = nil
|
|
||||||
|
|
||||||
// Move our window off screen to the initial animation position.
|
// Move our window off screen to the initial animation position.
|
||||||
position.setInitial(
|
position.setInitial(
|
||||||
in: window,
|
in: window,
|
||||||
on: screen,
|
on: screen,
|
||||||
terminalSize: derivedConfig.quickTerminalSize,
|
terminalSize: derivedConfig.quickTerminalSize,
|
||||||
closedFrame: lastClosedFrame)
|
closedFrame: closedFrame)
|
||||||
|
|
||||||
// We need to set our window level to a high value. In testing, only
|
// We need to set our window level to a high value. In testing, only
|
||||||
// popUpMenu and above do what we want. This gets it above the menu bar
|
// popUpMenu and above do what we want. This gets it above the menu bar
|
||||||
|
|
@ -403,7 +397,7 @@ class QuickTerminalController: BaseTerminalController {
|
||||||
in: window.animator(),
|
in: window.animator(),
|
||||||
on: screen,
|
on: screen,
|
||||||
terminalSize: derivedConfig.quickTerminalSize,
|
terminalSize: derivedConfig.quickTerminalSize,
|
||||||
closedFrame: lastClosedFrame)
|
closedFrame: closedFrame)
|
||||||
}, completionHandler: {
|
}, completionHandler: {
|
||||||
// There is a very minor delay here so waiting at least an event loop tick
|
// There is a very minor delay here so waiting at least an event loop tick
|
||||||
// keeps us safe from the view not being on the window.
|
// keeps us safe from the view not being on the window.
|
||||||
|
|
@ -491,8 +485,8 @@ class QuickTerminalController: BaseTerminalController {
|
||||||
// the user's preferred window size and position for when the quick
|
// the user's preferred window size and position for when the quick
|
||||||
// terminal is reactivated with a new surface. Without this, SwiftUI
|
// terminal is reactivated with a new surface. Without this, SwiftUI
|
||||||
// would reset the window to its minimum content size.
|
// would reset the window to its minimum content size.
|
||||||
if window.frame.width > 0 && window.frame.height > 0 {
|
if window.frame.width > 0 && window.frame.height > 0, let screen = window.screen {
|
||||||
lastClosedFrame = window.frame
|
screenStateCache.save(frame: window.frame, for: screen)
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we hid the dock then we unhide it.
|
// If we hid the dock then we unhide it.
|
||||||
|
|
@ -577,7 +571,6 @@ class QuickTerminalController: BaseTerminalController {
|
||||||
alert.alertStyle = .warning
|
alert.alertStyle = .warning
|
||||||
alert.beginSheetModal(for: window)
|
alert.beginSheetModal(for: window)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: First Responder
|
// MARK: First Responder
|
||||||
|
|
||||||
@IBAction override func closeWindow(_ sender: Any) {
|
@IBAction override func closeWindow(_ sender: Any) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,113 @@
|
||||||
|
import Foundation
|
||||||
|
import Cocoa
|
||||||
|
|
||||||
|
/// Manages cached window state per screen for the quick terminal.
|
||||||
|
///
|
||||||
|
/// This cache tracks the last closed window frame for each screen, allowing the quick terminal
|
||||||
|
/// to restore to its previous size and position when reopened. It uses stable display UUIDs
|
||||||
|
/// to survive NSScreen garbage collection and automatically prunes stale entries.
|
||||||
|
class QuickTerminalScreenStateCache {
|
||||||
|
/// The maximum number of saved screen states we retain. This is to avoid some kind of
|
||||||
|
/// pathological memory growth in case we get our screen state serializing wrong. I don't
|
||||||
|
/// know anyone with more than 10 screens, so let's just arbitrarily go with that.
|
||||||
|
private static let maxSavedScreens = 10
|
||||||
|
|
||||||
|
/// Time-to-live for screen entries that are no longer present (14 days).
|
||||||
|
private static let screenStaleTTL: TimeInterval = 14 * 24 * 60 * 60
|
||||||
|
|
||||||
|
/// Keyed by display UUID to survive NSScreen garbage collection.
|
||||||
|
private var stateByDisplay: [UUID: DisplayEntry] = [:]
|
||||||
|
|
||||||
|
init() {
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self,
|
||||||
|
selector: #selector(onScreensChanged(_:)),
|
||||||
|
name: NSApplication.didChangeScreenParametersNotification,
|
||||||
|
object: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
NotificationCenter.default.removeObserver(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save the window frame for a screen.
|
||||||
|
func save(frame: NSRect, for screen: NSScreen) {
|
||||||
|
guard let key = screen.displayUUID else { return }
|
||||||
|
let entry = DisplayEntry(
|
||||||
|
frame: frame,
|
||||||
|
screenSize: screen.frame.size,
|
||||||
|
scale: screen.backingScaleFactor,
|
||||||
|
lastSeen: Date()
|
||||||
|
)
|
||||||
|
stateByDisplay[key] = entry
|
||||||
|
pruneCapacity()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieve the last closed frame for a screen, if valid.
|
||||||
|
func frame(for screen: NSScreen) -> NSRect? {
|
||||||
|
guard let key = screen.displayUUID, var entry = stateByDisplay[key] else { return nil }
|
||||||
|
|
||||||
|
// Drop on dimension/scale change that makes the entry invalid
|
||||||
|
if !entry.isValid(for: screen) {
|
||||||
|
stateByDisplay.removeValue(forKey: key)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.lastSeen = Date()
|
||||||
|
stateByDisplay[key] = entry
|
||||||
|
return entry.frame
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func onScreensChanged(_ note: Notification) {
|
||||||
|
let screens = NSScreen.screens
|
||||||
|
let now = Date()
|
||||||
|
let currentIDs = Set(screens.compactMap { $0.displayUUID })
|
||||||
|
|
||||||
|
for screen in screens {
|
||||||
|
guard let key = screen.displayUUID else { continue }
|
||||||
|
if var entry = stateByDisplay[key] {
|
||||||
|
// Drop on dimension/scale change that makes the entry invalid
|
||||||
|
if !entry.isValid(for: screen) {
|
||||||
|
stateByDisplay.removeValue(forKey: key)
|
||||||
|
} else {
|
||||||
|
// Update the screen size if it grew (keep entry valid for larger screens)
|
||||||
|
entry.screenSize = screen.frame.size
|
||||||
|
entry.lastSeen = now
|
||||||
|
stateByDisplay[key] = entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TTL prune for non-present screens
|
||||||
|
stateByDisplay = stateByDisplay.filter { key, entry in
|
||||||
|
currentIDs.contains(key) || now.timeIntervalSince(entry.lastSeen) < Self.screenStaleTTL
|
||||||
|
}
|
||||||
|
|
||||||
|
pruneCapacity()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func pruneCapacity() {
|
||||||
|
guard stateByDisplay.count > Self.maxSavedScreens else { return }
|
||||||
|
let toRemove = stateByDisplay
|
||||||
|
.sorted { $0.value.lastSeen < $1.value.lastSeen }
|
||||||
|
.prefix(stateByDisplay.count - Self.maxSavedScreens)
|
||||||
|
for (key, _) in toRemove {
|
||||||
|
stateByDisplay.removeValue(forKey: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct DisplayEntry {
|
||||||
|
var frame: NSRect
|
||||||
|
var screenSize: CGSize
|
||||||
|
var scale: CGFloat
|
||||||
|
var lastSeen: Date
|
||||||
|
|
||||||
|
/// Returns true if this entry is still valid for the given screen.
|
||||||
|
/// Valid if the scale matches and the cached size is not larger than the current screen size.
|
||||||
|
/// This allows entries to persist when screens grow, but invalidates them when screens shrink.
|
||||||
|
func isValid(for screen: NSScreen) -> Bool {
|
||||||
|
guard scale == screen.backingScaleFactor else { return false }
|
||||||
|
return screenSize.width <= screen.frame.size.width && screenSize.height <= screen.frame.size.height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -55,7 +55,10 @@ class ServiceProvider: NSObject {
|
||||||
_ = TerminalController.newWindow(delegate.ghostty, withBaseConfig: config)
|
_ = TerminalController.newWindow(delegate.ghostty, withBaseConfig: config)
|
||||||
|
|
||||||
case .tab:
|
case .tab:
|
||||||
_ = TerminalController.newTab(delegate.ghostty, withBaseConfig: config)
|
_ = TerminalController.newTab(
|
||||||
|
delegate.ghostty,
|
||||||
|
from: TerminalController.preferredParent?.window,
|
||||||
|
withBaseConfig: config)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -551,23 +551,12 @@ class BaseTerminalController: NSWindowController,
|
||||||
// Get the direction from the notification
|
// Get the direction from the notification
|
||||||
guard let directionAny = notification.userInfo?[Ghostty.Notification.SplitDirectionKey] else { return }
|
guard let directionAny = notification.userInfo?[Ghostty.Notification.SplitDirectionKey] else { return }
|
||||||
guard let direction = directionAny as? Ghostty.SplitFocusDirection else { return }
|
guard let direction = directionAny as? Ghostty.SplitFocusDirection else { return }
|
||||||
|
|
||||||
// Convert Ghostty.SplitFocusDirection to our SplitTree.FocusDirection
|
|
||||||
let focusDirection: SplitTree<Ghostty.SurfaceView>.FocusDirection
|
|
||||||
switch direction {
|
|
||||||
case .previous: focusDirection = .previous
|
|
||||||
case .next: focusDirection = .next
|
|
||||||
case .up: focusDirection = .spatial(.up)
|
|
||||||
case .down: focusDirection = .spatial(.down)
|
|
||||||
case .left: focusDirection = .spatial(.left)
|
|
||||||
case .right: focusDirection = .spatial(.right)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the node for the target surface
|
// Find the node for the target surface
|
||||||
guard let targetNode = surfaceTree.root?.node(view: target) else { return }
|
guard let targetNode = surfaceTree.root?.node(view: target) else { return }
|
||||||
|
|
||||||
// Find the next surface to focus
|
// Find the next surface to focus
|
||||||
guard let nextSurface = surfaceTree.focusTarget(for: focusDirection, from: targetNode) else {
|
guard let nextSurface = surfaceTree.focusTarget(for: direction.toSplitTreeFocusDirection(), from: targetNode) else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -688,6 +677,8 @@ class BaseTerminalController: NSWindowController,
|
||||||
surfaceTree.contains(titleSurface) {
|
surfaceTree.contains(titleSurface) {
|
||||||
// If we have a surface, we want to listen for title changes.
|
// If we have a surface, we want to listen for title changes.
|
||||||
titleSurface.$title
|
titleSurface.$title
|
||||||
|
.combineLatest(titleSurface.$bell)
|
||||||
|
.map { [weak self] in self?.computeTitle(title: $0, bell: $1) ?? "" }
|
||||||
.sink { [weak self] in self?.titleDidChange(to: $0) }
|
.sink { [weak self] in self?.titleDidChange(to: $0) }
|
||||||
.store(in: &focusedSurfaceCancellables)
|
.store(in: &focusedSurfaceCancellables)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -695,8 +686,17 @@ class BaseTerminalController: NSWindowController,
|
||||||
titleDidChange(to: "👻")
|
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 }
|
guard let window else { return }
|
||||||
|
|
||||||
// Set the main window title
|
// Set the main window title
|
||||||
|
|
@ -717,6 +717,10 @@ class BaseTerminalController: NSWindowController,
|
||||||
|
|
||||||
func cellSizeDidChange(to: NSSize) {
|
func cellSizeDidChange(to: NSSize) {
|
||||||
guard derivedConfig.windowStepResize else { return }
|
guard derivedConfig.windowStepResize else { return }
|
||||||
|
// Stage manager can sometimes present windows in such a way that the
|
||||||
|
// cell size is temporarily zero due to the window being tiny. We can't
|
||||||
|
// set content resize increments to this value, so avoid an assertion failure.
|
||||||
|
guard to.width > 0 && to.height > 0 else { return }
|
||||||
self.window?.contentResizeIncrements = to
|
self.window?.contentResizeIncrements = to
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -863,14 +867,6 @@ class BaseTerminalController: NSWindowController,
|
||||||
// Everything beyond here is setting up the window
|
// Everything beyond here is setting up the window
|
||||||
guard let window else { return }
|
guard let window else { return }
|
||||||
|
|
||||||
// If there is a hardcoded title in the configuration, we set that
|
|
||||||
// immediately. Future `set_title` apprt actions will override this
|
|
||||||
// if necessary but this ensures our window loads with the proper
|
|
||||||
// title immediately rather than on another event loop tick (see #5934)
|
|
||||||
if let title = derivedConfig.title {
|
|
||||||
window.title = title
|
|
||||||
}
|
|
||||||
|
|
||||||
// We always initialize our fullscreen style to native if we can because
|
// We always initialize our fullscreen style to native if we can because
|
||||||
// initialization sets up some state (i.e. observers). If its set already
|
// initialization sets up some state (i.e. observers). If its set already
|
||||||
// somehow we don't do this.
|
// somehow we don't do this.
|
||||||
|
|
@ -1072,20 +1068,17 @@ class BaseTerminalController: NSWindowController,
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct DerivedConfig {
|
private struct DerivedConfig {
|
||||||
let title: String?
|
|
||||||
let macosTitlebarProxyIcon: Ghostty.MacOSTitlebarProxyIcon
|
let macosTitlebarProxyIcon: Ghostty.MacOSTitlebarProxyIcon
|
||||||
let windowStepResize: Bool
|
let windowStepResize: Bool
|
||||||
let focusFollowsMouse: Bool
|
let focusFollowsMouse: Bool
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
self.title = nil
|
|
||||||
self.macosTitlebarProxyIcon = .visible
|
self.macosTitlebarProxyIcon = .visible
|
||||||
self.windowStepResize = false
|
self.windowStepResize = false
|
||||||
self.focusFollowsMouse = false
|
self.focusFollowsMouse = false
|
||||||
}
|
}
|
||||||
|
|
||||||
init(_ config: Ghostty.Config) {
|
init(_ config: Ghostty.Config) {
|
||||||
self.title = config.title
|
|
||||||
self.macosTitlebarProxyIcon = config.macosTitlebarProxyIcon
|
self.macosTitlebarProxyIcon = config.macosTitlebarProxyIcon
|
||||||
self.windowStepResize = config.windowStepResize
|
self.windowStepResize = config.windowStepResize
|
||||||
self.focusFollowsMouse = config.focusFollowsMouse
|
self.focusFollowsMouse = config.focusFollowsMouse
|
||||||
|
|
|
||||||
|
|
@ -184,8 +184,14 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
||||||
static var preferredParent: TerminalController? {
|
static var preferredParent: TerminalController? {
|
||||||
all.first {
|
all.first {
|
||||||
$0.window?.isMainWindow ?? false
|
$0.window?.isMainWindow ?? false
|
||||||
} ?? all.last
|
} ?? lastMain ?? all.last
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The last controller to be main. We use this when paired with "preferredParent"
|
||||||
|
// to find the preferred window to attach new tabs, perform actions, etc. We
|
||||||
|
// always prefer the main window but if there isn't any (because we're triggered
|
||||||
|
// by something like an App Intent) then we prefer the most previous main.
|
||||||
|
static private(set) weak var lastMain: TerminalController? = nil
|
||||||
|
|
||||||
/// The "new window" action.
|
/// The "new window" action.
|
||||||
static func newWindow(
|
static func newWindow(
|
||||||
|
|
@ -521,7 +527,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
||||||
frame.origin.x = max(screen.frame.origin.x, min(frame.origin.x, screen.frame.maxX - newWidth))
|
frame.origin.x = max(screen.frame.origin.x, min(frame.origin.x, screen.frame.maxX - newWidth))
|
||||||
frame.origin.y = max(screen.frame.origin.y, min(frame.origin.y, screen.frame.maxY - newHeight))
|
frame.origin.y = max(screen.frame.origin.y, min(frame.origin.y, screen.frame.maxY - newHeight))
|
||||||
|
|
||||||
return frame
|
return adjustForWindowPosition(frame: frame, on: screen)
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let initialFrame else { return nil }
|
guard let initialFrame else { return nil }
|
||||||
|
|
@ -539,7 +545,30 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
||||||
frame.origin.x = max(screen.frame.origin.x, min(frame.origin.x, screen.frame.maxX - newWidth))
|
frame.origin.x = max(screen.frame.origin.x, min(frame.origin.x, screen.frame.maxX - newWidth))
|
||||||
frame.origin.y = max(screen.frame.origin.y, min(frame.origin.y, screen.frame.maxY - newHeight))
|
frame.origin.y = max(screen.frame.origin.y, min(frame.origin.y, screen.frame.maxY - newHeight))
|
||||||
|
|
||||||
return frame
|
return adjustForWindowPosition(frame: frame, on: screen)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adjusts the given frame for the configured window position.
|
||||||
|
func adjustForWindowPosition(frame: NSRect, on screen: NSScreen) -> NSRect {
|
||||||
|
guard let x = derivedConfig.windowPositionX else { return frame }
|
||||||
|
guard let y = derivedConfig.windowPositionY else { return frame }
|
||||||
|
|
||||||
|
// Convert top-left coordinates to bottom-left origin using our utility extension
|
||||||
|
let origin = screen.origin(
|
||||||
|
fromTopLeftOffsetX: CGFloat(x),
|
||||||
|
offsetY: CGFloat(y),
|
||||||
|
windowSize: frame.size)
|
||||||
|
|
||||||
|
// Clamp the origin to ensure the window stays fully visible on screen
|
||||||
|
var safeOrigin = origin
|
||||||
|
let vf = screen.visibleFrame
|
||||||
|
safeOrigin.x = min(max(safeOrigin.x, vf.minX), vf.maxX - frame.width)
|
||||||
|
safeOrigin.y = min(max(safeOrigin.y, vf.minY), vf.maxY - frame.height)
|
||||||
|
|
||||||
|
// Return our new origin
|
||||||
|
var result = frame
|
||||||
|
result.origin = safeOrigin
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
/// This is called anytime a node in the surface tree is being removed.
|
/// This is called anytime a node in the surface tree is being removed.
|
||||||
|
|
@ -1036,6 +1065,9 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
||||||
if let window {
|
if let window {
|
||||||
LastWindowPosition.shared.save(window)
|
LastWindowPosition.shared.save(window)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remember our last main
|
||||||
|
Self.lastMain = self
|
||||||
}
|
}
|
||||||
|
|
||||||
// Called when the window will be encoded. We handle the data encoding here in the
|
// Called when the window will be encoded. We handle the data encoding here in the
|
||||||
|
|
@ -1349,12 +1381,16 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
||||||
let macosWindowButtons: Ghostty.MacOSWindowButtons
|
let macosWindowButtons: Ghostty.MacOSWindowButtons
|
||||||
let macosTitlebarStyle: String
|
let macosTitlebarStyle: String
|
||||||
let maximize: Bool
|
let maximize: Bool
|
||||||
|
let windowPositionX: Int16?
|
||||||
|
let windowPositionY: Int16?
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
self.backgroundColor = Color(NSColor.windowBackgroundColor)
|
self.backgroundColor = Color(NSColor.windowBackgroundColor)
|
||||||
self.macosWindowButtons = .visible
|
self.macosWindowButtons = .visible
|
||||||
self.macosTitlebarStyle = "system"
|
self.macosTitlebarStyle = "system"
|
||||||
self.maximize = false
|
self.maximize = false
|
||||||
|
self.windowPositionX = nil
|
||||||
|
self.windowPositionY = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
init(_ config: Ghostty.Config) {
|
init(_ config: Ghostty.Config) {
|
||||||
|
|
@ -1362,6 +1398,8 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
||||||
self.macosWindowButtons = config.macosWindowButtons
|
self.macosWindowButtons = config.macosWindowButtons
|
||||||
self.macosTitlebarStyle = config.macosTitlebarStyle
|
self.macosTitlebarStyle = config.macosTitlebarStyle
|
||||||
self.maximize = config.maximize
|
self.maximize = config.maximize
|
||||||
|
self.windowPositionX = config.windowPositionX
|
||||||
|
self.windowPositionY = config.windowPositionY
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,14 @@ class TerminalWindow: NSWindow {
|
||||||
|
|
||||||
// Setup our initial config
|
// Setup our initial config
|
||||||
derivedConfig = .init(config)
|
derivedConfig = .init(config)
|
||||||
|
|
||||||
|
// If there is a hardcoded title in the configuration, we set that
|
||||||
|
// immediately. Future `set_title` apprt actions will override this
|
||||||
|
// if necessary but this ensures our window loads with the proper
|
||||||
|
// title immediately rather than on another event loop tick (see #5934)
|
||||||
|
if let title = derivedConfig.title {
|
||||||
|
self.title = title
|
||||||
|
}
|
||||||
|
|
||||||
// If window decorations are disabled, remove our title
|
// If window decorations are disabled, remove our title
|
||||||
if (!config.windowDecorations) { styleMask.remove(.titled) }
|
if (!config.windowDecorations) { styleMask.remove(.titled) }
|
||||||
|
|
@ -57,8 +65,7 @@ class TerminalWindow: NSWindow {
|
||||||
// fallback to original centering behavior
|
// fallback to original centering behavior
|
||||||
setInitialWindowPosition(
|
setInitialWindowPosition(
|
||||||
x: config.windowPositionX,
|
x: config.windowPositionX,
|
||||||
y: config.windowPositionY,
|
y: config.windowPositionY)
|
||||||
windowDecorations: config.windowDecorations)
|
|
||||||
|
|
||||||
// If our traffic buttons should be hidden, then hide them
|
// If our traffic buttons should be hidden, then hide them
|
||||||
if config.macosWindowButtons == .hidden {
|
if config.macosWindowButtons == .hidden {
|
||||||
|
|
@ -116,6 +123,12 @@ class TerminalWindow: NSWindow {
|
||||||
} else {
|
} else {
|
||||||
tabBarDidDisappear()
|
tabBarDidDisappear()
|
||||||
}
|
}
|
||||||
|
viewModel.isMainWindow = true
|
||||||
|
}
|
||||||
|
|
||||||
|
override func resignMain() {
|
||||||
|
super.resignMain()
|
||||||
|
viewModel.isMainWindow = false
|
||||||
}
|
}
|
||||||
|
|
||||||
override func mergeAllWindows(_ sender: Any?) {
|
override func mergeAllWindows(_ sender: Any?) {
|
||||||
|
|
@ -156,9 +169,16 @@ class TerminalWindow: NSWindow {
|
||||||
|
|
||||||
/// Returns true if there is a tab bar visible on this window.
|
/// Returns true if there is a tab bar visible on this window.
|
||||||
var hasTabBar: Bool {
|
var hasTabBar: Bool {
|
||||||
|
// TODO: use titlebarView to find it instead
|
||||||
contentView?.firstViewFromRoot(withClassName: "NSTabBar") != nil
|
contentView?.firstViewFromRoot(withClassName: "NSTabBar") != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var hasMoreThanOneTabs: Bool {
|
||||||
|
/// accessing ``tabGroup?.windows`` here
|
||||||
|
/// will cause other edge cases, be careful
|
||||||
|
(tabbedWindows?.count ?? 0) > 1
|
||||||
|
}
|
||||||
|
|
||||||
func isTabBar(_ childViewController: NSTitlebarAccessoryViewController) -> Bool {
|
func isTabBar(_ childViewController: NSTitlebarAccessoryViewController) -> Bool {
|
||||||
if childViewController.identifier == nil {
|
if childViewController.identifier == nil {
|
||||||
// The good case
|
// The good case
|
||||||
|
|
@ -252,7 +272,7 @@ class TerminalWindow: NSWindow {
|
||||||
button.isBordered = false
|
button.isBordered = false
|
||||||
button.allowsExpansionToolTips = true
|
button.allowsExpansionToolTips = true
|
||||||
button.toolTip = "Reset Zoom"
|
button.toolTip = "Reset Zoom"
|
||||||
button.contentTintColor = .controlAccentColor
|
button.contentTintColor = isMainWindow ? .controlAccentColor : .secondaryLabelColor
|
||||||
button.state = .on
|
button.state = .on
|
||||||
button.image = NSImage(named:"ResetZoom")
|
button.image = NSImage(named:"ResetZoom")
|
||||||
button.frame = NSRect(x: 0, y: 0, width: 20, height: 20)
|
button.frame = NSRect(x: 0, y: 0, width: 20, height: 20)
|
||||||
|
|
@ -269,6 +289,12 @@ class TerminalWindow: NSWindow {
|
||||||
// Whenever we change the window title we must also update our
|
// Whenever we change the window title we must also update our
|
||||||
// tab title if we're using custom fonts.
|
// tab title if we're using custom fonts.
|
||||||
tab.attributedTitle = attributedTitle
|
tab.attributedTitle = attributedTitle
|
||||||
|
/// We also needs to update this here, just in case
|
||||||
|
/// the value is not what we want
|
||||||
|
///
|
||||||
|
/// Check ``titlebarFont`` down below
|
||||||
|
/// to see why we need to check `hasMoreThanOneTabs` here
|
||||||
|
titlebarTextField?.usesSingleLineMode = !hasMoreThanOneTabs
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -278,6 +304,12 @@ class TerminalWindow: NSWindow {
|
||||||
let font = titlebarFont ?? NSFont.titleBarFont(ofSize: NSFont.systemFontSize)
|
let font = titlebarFont ?? NSFont.titleBarFont(ofSize: NSFont.systemFontSize)
|
||||||
|
|
||||||
titlebarTextField?.font = font
|
titlebarTextField?.font = font
|
||||||
|
/// We check `hasMoreThanOneTabs` here because the system
|
||||||
|
/// may copy this setting to the tab’s text field at some point(e.g. entering/exiting fullscreen),
|
||||||
|
/// which can cause the title to be vertically misaligned (shifted downward).
|
||||||
|
///
|
||||||
|
/// This behaviour is the opposite of what happens in the title bar’s text field, which is quite odd...
|
||||||
|
titlebarTextField?.usesSingleLineMode = !hasMoreThanOneTabs
|
||||||
tab.attributedTitle = attributedTitle
|
tab.attributedTitle = attributedTitle
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -392,7 +424,7 @@ class TerminalWindow: NSWindow {
|
||||||
return derivedConfig.backgroundColor.withAlphaComponent(alpha)
|
return derivedConfig.backgroundColor.withAlphaComponent(alpha)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setInitialWindowPosition(x: Int16?, y: Int16?, windowDecorations: Bool) {
|
private func setInitialWindowPosition(x: Int16?, y: Int16?) {
|
||||||
// If we don't have an X/Y then we try to use the previously saved window pos.
|
// If we don't have an X/Y then we try to use the previously saved window pos.
|
||||||
guard let x, let y else {
|
guard let x, let y else {
|
||||||
if (!LastWindowPosition.shared.restore(self)) {
|
if (!LastWindowPosition.shared.restore(self)) {
|
||||||
|
|
@ -408,11 +440,14 @@ class TerminalWindow: NSWindow {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Orient based on the top left of the primary monitor
|
// We have an X/Y, use our controller function to set it up.
|
||||||
let frame = screen.visibleFrame
|
guard let terminalController else {
|
||||||
setFrameOrigin(.init(
|
center()
|
||||||
x: frame.minX + CGFloat(x),
|
return
|
||||||
y: frame.maxY - (CGFloat(y) + frame.height)))
|
}
|
||||||
|
|
||||||
|
let frame = terminalController.adjustForWindowPosition(frame: frame, on: screen)
|
||||||
|
setFrameOrigin(frame.origin)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func hideWindowButtons() {
|
private func hideWindowButtons() {
|
||||||
|
|
@ -424,17 +459,20 @@ class TerminalWindow: NSWindow {
|
||||||
// MARK: Config
|
// MARK: Config
|
||||||
|
|
||||||
struct DerivedConfig {
|
struct DerivedConfig {
|
||||||
|
let title: String?
|
||||||
let backgroundColor: NSColor
|
let backgroundColor: NSColor
|
||||||
let backgroundOpacity: Double
|
let backgroundOpacity: Double
|
||||||
let macosWindowButtons: Ghostty.MacOSWindowButtons
|
let macosWindowButtons: Ghostty.MacOSWindowButtons
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
|
self.title = nil
|
||||||
self.backgroundColor = NSColor.windowBackgroundColor
|
self.backgroundColor = NSColor.windowBackgroundColor
|
||||||
self.backgroundOpacity = 1
|
self.backgroundOpacity = 1
|
||||||
self.macosWindowButtons = .visible
|
self.macosWindowButtons = .visible
|
||||||
}
|
}
|
||||||
|
|
||||||
init(_ config: Ghostty.Config) {
|
init(_ config: Ghostty.Config) {
|
||||||
|
self.title = config.title
|
||||||
self.backgroundColor = NSColor(config.backgroundColor)
|
self.backgroundColor = NSColor(config.backgroundColor)
|
||||||
self.backgroundOpacity = config.backgroundOpacity
|
self.backgroundOpacity = config.backgroundOpacity
|
||||||
self.macosWindowButtons = config.macosWindowButtons
|
self.macosWindowButtons = config.macosWindowButtons
|
||||||
|
|
@ -448,6 +486,7 @@ extension TerminalWindow {
|
||||||
class ViewModel: ObservableObject {
|
class ViewModel: ObservableObject {
|
||||||
@Published var isSurfaceZoomed: Bool = false
|
@Published var isSurfaceZoomed: Bool = false
|
||||||
@Published var hasToolbar: Bool = false
|
@Published var hasToolbar: Bool = false
|
||||||
|
@Published var isMainWindow: Bool = true
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ResetZoomAccessoryView: View {
|
struct ResetZoomAccessoryView: View {
|
||||||
|
|
@ -469,7 +508,7 @@ extension TerminalWindow {
|
||||||
VStack {
|
VStack {
|
||||||
Button(action: action) {
|
Button(action: action) {
|
||||||
Image("ResetZoom")
|
Image("ResetZoom")
|
||||||
.foregroundColor(.accentColor)
|
.foregroundColor(viewModel.isMainWindow ? .accentColor : .secondary)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.help("Reset Split Zoom")
|
.help("Reset Split Zoom")
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,21 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool
|
||||||
|
|
||||||
// MARK: NSWindow
|
// MARK: NSWindow
|
||||||
|
|
||||||
|
override var titlebarFont: NSFont? {
|
||||||
|
didSet {
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.viewModel.titleFont = self.titlebarFont
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override var title: String {
|
override var title: String {
|
||||||
didSet {
|
didSet {
|
||||||
viewModel.title = title
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.viewModel.title = self.title
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -42,17 +54,33 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool
|
||||||
// Check if we have a tab bar and set it up if we have to. See the comment
|
// Check if we have a tab bar and set it up if we have to. See the comment
|
||||||
// on this function to learn why we need to check this here.
|
// on this function to learn why we need to check this here.
|
||||||
setupTabBar()
|
setupTabBar()
|
||||||
|
|
||||||
|
viewModel.isMainWindow = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override func resignMain() {
|
||||||
|
super.resignMain()
|
||||||
|
|
||||||
|
viewModel.isMainWindow = false
|
||||||
|
}
|
||||||
// This is called by macOS for native tabbing in order to add the tab bar. We hook into
|
// This is called by macOS for native tabbing in order to add the tab bar. We hook into
|
||||||
// this, detect the tab bar being added, and override its behavior.
|
// this, detect the tab bar being added, and override its behavior.
|
||||||
override func addTitlebarAccessoryViewController(_ childViewController: NSTitlebarAccessoryViewController) {
|
override func addTitlebarAccessoryViewController(_ childViewController: NSTitlebarAccessoryViewController) {
|
||||||
// If this is the tab bar then we need to set it up for the titlebar
|
// If this is the tab bar then we need to set it up for the titlebar
|
||||||
guard isTabBar(childViewController) else {
|
guard isTabBar(childViewController) else {
|
||||||
|
// After dragging a tab into a new window, `hasTabBar` needs to be
|
||||||
|
// updated to properly review window title
|
||||||
|
viewModel.hasTabBar = false
|
||||||
|
|
||||||
super.addTitlebarAccessoryViewController(childViewController)
|
super.addTitlebarAccessoryViewController(childViewController)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When an existing tab is being dragged in to another tab group,
|
||||||
|
// system will also try to add tab bar to this window, so we want to reset observer,
|
||||||
|
// to put tab bar where we want again
|
||||||
|
tabBarObserver = nil
|
||||||
|
|
||||||
// Some setup needs to happen BEFORE it is added, such as layout. If
|
// Some setup needs to happen BEFORE it is added, such as layout. If
|
||||||
// we don't do this before the call below, we'll trigger an AppKit
|
// we don't do this before the call below, we'll trigger an AppKit
|
||||||
// assertion.
|
// assertion.
|
||||||
|
|
@ -112,18 +140,33 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool
|
||||||
guard tabBarObserver == nil else { return }
|
guard tabBarObserver == nil else { return }
|
||||||
|
|
||||||
// Find our tab bar. If it doesn't exist we don't do anything.
|
// Find our tab bar. If it doesn't exist we don't do anything.
|
||||||
guard let tabBar = contentView?.rootView.firstDescendant(withClassName: "NSTabBar") else { return }
|
//
|
||||||
|
// In normal window, `NSTabBar` typically appears as a subview of `NSTitlebarView` within `NSThemeFrame`.
|
||||||
|
// In fullscreen, the system creates a dedicated fullscreen window and the view hierarchy changes;
|
||||||
|
// in that case, the `titlebarView` is only accessible via a reference on `NSThemeFrame`.
|
||||||
|
// ref: https://github.com/mozilla-firefox/firefox/blob/054e2b072785984455b3b59acad9444ba1eeffb4/widget/cocoa/nsCocoaWindow.mm#L7205
|
||||||
|
guard let themeFrameView = contentView?.rootView else { return }
|
||||||
|
let titlebarView = if themeFrameView.responds(to: Selector(("titlebarView"))) {
|
||||||
|
themeFrameView.value(forKey: "titlebarView") as? NSView
|
||||||
|
} else {
|
||||||
|
NSView?.none
|
||||||
|
}
|
||||||
|
guard let tabBar = titlebarView?.firstDescendant(withClassName: "NSTabBar") else { return }
|
||||||
|
|
||||||
// View model updates must happen on their own ticks.
|
// View model updates must happen on their own ticks.
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async { [weak self] in
|
||||||
self.viewModel.hasTabBar = true
|
self?.viewModel.hasTabBar = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find our clip view
|
// Find our clip view
|
||||||
guard let clipView = tabBar.firstSuperview(withClassName: "NSTitlebarAccessoryClipView") else { return }
|
guard let clipView = tabBar.firstSuperview(withClassName: "NSTitlebarAccessoryClipView") else { return }
|
||||||
guard let accessoryView = clipView.subviews[safe: 0] else { return }
|
guard let accessoryView = clipView.subviews[safe: 0] else { return }
|
||||||
guard let titlebarView = clipView.firstSuperview(withClassName: "NSTitlebarView") else { return }
|
guard let titlebarView else { return }
|
||||||
guard let toolbarView = titlebarView.firstDescendant(withClassName: "NSToolbarView") else { return }
|
guard let toolbarView = titlebarView.firstDescendant(withClassName: "NSToolbarView") else { return }
|
||||||
|
|
||||||
|
// Make sure tabBar's height won't be stretched
|
||||||
|
guard let newTabButton = titlebarView.firstDescendant(withClassName: "NSTabBarNewTabButton") else { return }
|
||||||
|
tabBar.frame.size.height = newTabButton.frame.width
|
||||||
|
|
||||||
// The container is the view that we'll constrain our tab bar within.
|
// The container is the view that we'll constrain our tab bar within.
|
||||||
let container = toolbarView
|
let container = toolbarView
|
||||||
|
|
@ -205,6 +248,8 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool
|
||||||
case .title:
|
case .title:
|
||||||
let item = NSToolbarItem(itemIdentifier: .title)
|
let item = NSToolbarItem(itemIdentifier: .title)
|
||||||
item.view = NSHostingView(rootView: TitleItem(viewModel: viewModel))
|
item.view = NSHostingView(rootView: TitleItem(viewModel: viewModel))
|
||||||
|
// Fix: https://github.com/ghostty-org/ghostty/discussions/9027
|
||||||
|
item.view?.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||||
item.visibilityPriority = .user
|
item.visibilityPriority = .user
|
||||||
item.isEnabled = true
|
item.isEnabled = true
|
||||||
|
|
||||||
|
|
@ -221,8 +266,10 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool
|
||||||
// MARK: SwiftUI
|
// MARK: SwiftUI
|
||||||
|
|
||||||
class ViewModel: ObservableObject {
|
class ViewModel: ObservableObject {
|
||||||
|
@Published var titleFont: NSFont?
|
||||||
@Published var title: String = "👻 Ghostty"
|
@Published var title: String = "👻 Ghostty"
|
||||||
@Published var hasTabBar: Bool = false
|
@Published var hasTabBar: Bool = false
|
||||||
|
@Published var isMainWindow: Bool = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -245,15 +292,24 @@ extension TitlebarTabsTahoeTerminalWindow {
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if !viewModel.hasTabBar {
|
if !viewModel.hasTabBar {
|
||||||
Text(title)
|
titleText
|
||||||
.lineLimit(1)
|
|
||||||
.truncationMode(.tail)
|
|
||||||
} else {
|
} else {
|
||||||
// 1x1.gif strikes again! For real: if we render a zero-sized
|
// 1x1.gif strikes again! For real: if we render a zero-sized
|
||||||
// view here then the toolbar just disappears our view. I don't
|
// view here then the toolbar just disappears our view. I don't
|
||||||
// know.
|
// know. This appears fixed in 26.1 Beta but keep it safe for 26.0.
|
||||||
Color.clear.frame(width: 1, height: 1)
|
Color.clear.frame(width: 1, height: 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
var titleText: some View {
|
||||||
|
Text(title)
|
||||||
|
.font(viewModel.titleFont.flatMap(Font.init(_:)))
|
||||||
|
.foregroundStyle(viewModel.isMainWindow ? .primary : .secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.tail)
|
||||||
|
.frame(maxWidth: .greatestFiniteMagnitude, alignment: .center)
|
||||||
|
.opacity(viewModel.hasTabBar ? 0 : 1) // hide when in fullscreen mode, where title bar will appear in the leading area under window buttons
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -141,6 +141,7 @@ class TitlebarTabsVenturaTerminalWindow: TerminalWindow {
|
||||||
super.syncAppearance(surfaceConfig)
|
super.syncAppearance(surfaceConfig)
|
||||||
|
|
||||||
// Update our window light/darkness based on our updated background color
|
// Update our window light/darkness based on our updated background color
|
||||||
|
let themeChanged = isLightTheme != OSColor(surfaceConfig.backgroundColor).isLightColor
|
||||||
isLightTheme = OSColor(surfaceConfig.backgroundColor).isLightColor
|
isLightTheme = OSColor(surfaceConfig.backgroundColor).isLightColor
|
||||||
|
|
||||||
// Update our titlebar color
|
// Update our titlebar color
|
||||||
|
|
@ -150,7 +151,7 @@ class TitlebarTabsVenturaTerminalWindow: TerminalWindow {
|
||||||
titlebarColor = derivedConfig.backgroundColor.withAlphaComponent(derivedConfig.backgroundOpacity)
|
titlebarColor = derivedConfig.backgroundColor.withAlphaComponent(derivedConfig.backgroundOpacity)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isOpaque) {
|
if (isOpaque || themeChanged) {
|
||||||
// If there is transparency, calling this will make the titlebar opaque
|
// If there is transparency, calling this will make the titlebar opaque
|
||||||
// so we only call this if we are opaque.
|
// so we only call this if we are opaque.
|
||||||
updateTabBar()
|
updateTabBar()
|
||||||
|
|
@ -183,41 +184,33 @@ class TitlebarTabsVenturaTerminalWindow: TerminalWindow {
|
||||||
// so we need to do it manually.
|
// so we need to do it manually.
|
||||||
private func updateNewTabButtonOpacity() {
|
private func updateNewTabButtonOpacity() {
|
||||||
guard let newTabButton: NSButton = titlebarContainer?.firstDescendant(withClassName: "NSTabBarNewTabButton") as? NSButton else { return }
|
guard let newTabButton: NSButton = titlebarContainer?.firstDescendant(withClassName: "NSTabBarNewTabButton") as? NSButton else { return }
|
||||||
guard let newTabButtonImageView: NSImageView = newTabButton.subviews.first(where: {
|
guard let newTabButtonImageView = newTabButton.firstDescendant(withClassName: "NSImageView") as? NSImageView else { return }
|
||||||
$0 as? NSImageView != nil
|
|
||||||
}) as? NSImageView else { return }
|
|
||||||
|
|
||||||
newTabButtonImageView.alphaValue = isKeyWindow ? 1 : 0.5
|
newTabButtonImageView.alphaValue = isKeyWindow ? 1 : 0.5
|
||||||
}
|
}
|
||||||
|
|
||||||
// Color the new tab button's image to match the color of the tab title/keyboard shortcut labels,
|
/// Update: This method only add a vibrant overlay now,
|
||||||
// just as it does in the stock tab bar.
|
/// since the image itself supports light/dark tint,
|
||||||
|
/// and system could restore it any time,
|
||||||
|
/// altering it will only cause maintenance burden for us.
|
||||||
|
///
|
||||||
|
/// And if we hide original image,
|
||||||
|
/// ``updateNewTabButtonOpacity`` will not work
|
||||||
|
///
|
||||||
|
/// ~~Color the new tab button's image to match the color of the tab title/keyboard shortcut labels,~~
|
||||||
|
/// ~~just as it does in the stock tab bar.~~
|
||||||
private func updateNewTabButtonImage() {
|
private func updateNewTabButtonImage() {
|
||||||
guard let newTabButton: NSButton = titlebarContainer?.firstDescendant(withClassName: "NSTabBarNewTabButton") as? NSButton else { return }
|
guard let newTabButton: NSButton = titlebarContainer?.firstDescendant(withClassName: "NSTabBarNewTabButton") as? NSButton else { return }
|
||||||
guard let newTabButtonImageView: NSImageView = newTabButton.subviews.first(where: {
|
guard let newTabButtonImageView = newTabButton.firstDescendant(withClassName: "NSImageView") as? NSImageView else { return }
|
||||||
$0 as? NSImageView != nil
|
|
||||||
}) as? NSImageView else { return }
|
|
||||||
guard let newTabButtonImage = newTabButtonImageView.image else { return }
|
guard let newTabButtonImage = newTabButtonImageView.image else { return }
|
||||||
|
|
||||||
|
let imageLayer = newTabButtonImageLayer ?? VibrantLayer(forAppearance: isLightTheme ? .light : .dark)!
|
||||||
|
imageLayer.frame = NSRect(origin: NSPoint(x: newTabButton.bounds.midX - newTabButtonImage.size.width/2, y: newTabButton.bounds.midY - newTabButtonImage.size.height/2), size: newTabButtonImage.size)
|
||||||
|
imageLayer.contentsGravity = .resizeAspect
|
||||||
|
imageLayer.opacity = 0.5
|
||||||
|
|
||||||
if newTabButtonImageLayer == nil {
|
newTabButtonImageLayer = imageLayer
|
||||||
let fillColor: NSColor = isLightTheme ? .black.withAlphaComponent(0.85) : .white.withAlphaComponent(0.85)
|
|
||||||
let newImage = NSImage(size: newTabButtonImage.size, flipped: false) { rect in
|
|
||||||
newTabButtonImage.draw(in: rect)
|
|
||||||
fillColor.setFill()
|
|
||||||
rect.fill(using: .sourceAtop)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
let imageLayer = VibrantLayer(forAppearance: isLightTheme ? .light : .dark)!
|
|
||||||
imageLayer.frame = NSRect(origin: NSPoint(x: newTabButton.bounds.midX - newTabButtonImage.size.width/2, y: newTabButton.bounds.midY - newTabButtonImage.size.height/2), size: newTabButtonImage.size)
|
|
||||||
imageLayer.contentsGravity = .resizeAspect
|
|
||||||
imageLayer.contents = newImage
|
|
||||||
imageLayer.opacity = 0.5
|
|
||||||
|
|
||||||
newTabButtonImageLayer = imageLayer
|
|
||||||
}
|
|
||||||
|
|
||||||
newTabButtonImageView.isHidden = true
|
|
||||||
newTabButton.layer?.sublayers?.first(where: { $0.className == "VibrantLayer" })?.removeFromSuperlayer()
|
newTabButton.layer?.sublayers?.first(where: { $0.className == "VibrantLayer" })?.removeFromSuperlayer()
|
||||||
newTabButton.layer?.addSublayer(newTabButtonImageLayer!)
|
newTabButton.layer?.addSublayer(newTabButtonImageLayer!)
|
||||||
}
|
}
|
||||||
|
|
@ -448,6 +441,13 @@ class TitlebarTabsVenturaTerminalWindow: TerminalWindow {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func addWindowButtonsBackdrop(titlebarView: NSView, toolbarView: NSView) {
|
private func addWindowButtonsBackdrop(titlebarView: NSView, toolbarView: NSView) {
|
||||||
|
guard windowButtonsBackdrop?.superview != titlebarView else {
|
||||||
|
/// replacing existing backdrop aggressively
|
||||||
|
/// may cause incorrect hierarchy
|
||||||
|
///
|
||||||
|
/// because multiple windows are adding this around the 'same time'
|
||||||
|
return
|
||||||
|
}
|
||||||
windowButtonsBackdrop?.removeFromSuperview()
|
windowButtonsBackdrop?.removeFromSuperview()
|
||||||
windowButtonsBackdrop = nil
|
windowButtonsBackdrop = nil
|
||||||
|
|
||||||
|
|
@ -466,16 +466,11 @@ class TitlebarTabsVenturaTerminalWindow: TerminalWindow {
|
||||||
|
|
||||||
private func addWindowDragHandle(titlebarView: NSView, toolbarView: NSView) {
|
private func addWindowDragHandle(titlebarView: NSView, toolbarView: NSView) {
|
||||||
// If we already made the view, just make sure it's unhidden and correctly placed as a subview.
|
// If we already made the view, just make sure it's unhidden and correctly placed as a subview.
|
||||||
if let view = windowDragHandle {
|
guard windowDragHandle?.superview != titlebarView.superview else {
|
||||||
view.removeFromSuperview()
|
// similar to `addWindowButtonsBackdrop`
|
||||||
view.isHidden = false
|
|
||||||
titlebarView.superview?.addSubview(view)
|
|
||||||
view.leftAnchor.constraint(equalTo: toolbarView.leftAnchor).isActive = true
|
|
||||||
view.rightAnchor.constraint(equalTo: toolbarView.rightAnchor).isActive = true
|
|
||||||
view.topAnchor.constraint(equalTo: toolbarView.topAnchor).isActive = true
|
|
||||||
view.bottomAnchor.constraint(equalTo: toolbarView.topAnchor, constant: 12).isActive = true
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
windowDragHandle?.removeFromSuperview()
|
||||||
|
|
||||||
let view = WindowDragView()
|
let view = WindowDragView()
|
||||||
view.identifier = NSUserInterfaceItemIdentifier("_windowDragHandle")
|
view.identifier = NSUserInterfaceItemIdentifier("_windowDragHandle")
|
||||||
|
|
@ -536,7 +531,10 @@ fileprivate class WindowButtonsBackdropView: NSView {
|
||||||
// This must be weak because the window has this view. Otherwise
|
// This must be weak because the window has this view. Otherwise
|
||||||
// a retain cycle occurs.
|
// a retain cycle occurs.
|
||||||
private weak var terminalWindow: TitlebarTabsVenturaTerminalWindow?
|
private weak var terminalWindow: TitlebarTabsVenturaTerminalWindow?
|
||||||
private let isLightTheme: Bool
|
private var isLightTheme: Bool {
|
||||||
|
// using up-to-date value from hosting window directly
|
||||||
|
terminalWindow?.isLightTheme ?? false
|
||||||
|
}
|
||||||
private let overlayLayer = VibrantLayer()
|
private let overlayLayer = VibrantLayer()
|
||||||
|
|
||||||
var isHighlighted: Bool = true {
|
var isHighlighted: Bool = true {
|
||||||
|
|
@ -565,7 +563,6 @@ fileprivate class WindowButtonsBackdropView: NSView {
|
||||||
|
|
||||||
init(window: TitlebarTabsVenturaTerminalWindow) {
|
init(window: TitlebarTabsVenturaTerminalWindow) {
|
||||||
self.terminalWindow = window
|
self.terminalWindow = window
|
||||||
self.isLightTheme = window.isLightTheme
|
|
||||||
|
|
||||||
super.init(frame: .zero)
|
super.init(frame: .zero)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -99,10 +99,13 @@ extension Ghostty.Action {
|
||||||
|
|
||||||
let state: State
|
let state: State
|
||||||
let progress: UInt8?
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -624,10 +624,15 @@ extension Ghostty {
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
let action = Ghostty.Action.OpenURL(c: v)
|
let action = Ghostty.Action.OpenURL(c: v)
|
||||||
|
|
||||||
// Convert the URL string to a URL object
|
// If the URL doesn't have a valid scheme we assume its a file path. The URL
|
||||||
guard let url = URL(string: action.url) else {
|
// initializer will gladly take invalid URLs (e.g. plain file paths) and turn
|
||||||
Ghostty.logger.warning("invalid URL for open URL action: \(action.url)")
|
// them into schema-less URLs, but these won't open properly in text editors.
|
||||||
return false
|
// See: https://github.com/ghostty-org/ghostty/issues/8763
|
||||||
|
let url: URL
|
||||||
|
if let candidate = URL(string: action.url), candidate.scheme != nil {
|
||||||
|
url = candidate
|
||||||
|
} else {
|
||||||
|
url = URL(filePath: action.url)
|
||||||
}
|
}
|
||||||
|
|
||||||
switch action.kind {
|
switch action.kind {
|
||||||
|
|
@ -1020,26 +1025,38 @@ extension Ghostty {
|
||||||
guard let surfaceView = self.surfaceView(from: surface) else { return false }
|
guard let surfaceView = self.surfaceView(from: surface) else { return false }
|
||||||
guard let controller = surfaceView.window?.windowController as? BaseTerminalController else { return false }
|
guard let controller = surfaceView.window?.windowController as? BaseTerminalController else { return false }
|
||||||
|
|
||||||
// For now, we return false if the window has no splits and we return
|
// If the window has no splits, the action is not performable
|
||||||
// true if the window has ANY splits. This isn't strictly correct because
|
|
||||||
// we should only be returning true if we actually performed the action,
|
|
||||||
// but this handles the most common case of caring about goto_split performability
|
|
||||||
// which is the no-split case.
|
|
||||||
guard controller.surfaceTree.isSplit else { return false }
|
guard controller.surfaceTree.isSplit else { return false }
|
||||||
|
|
||||||
|
// Convert the C API direction to our Swift type
|
||||||
|
guard let splitDirection = SplitFocusDirection.from(direction: direction) else { return false }
|
||||||
|
|
||||||
|
// Find the current node in the tree
|
||||||
|
guard let targetNode = controller.surfaceTree.root?.node(view: surfaceView) else { return false }
|
||||||
|
|
||||||
|
// Check if a split actually exists in the target direction before
|
||||||
|
// returning true. This ensures performable keybinds only consume
|
||||||
|
// the key event when we actually perform navigation.
|
||||||
|
let focusDirection: SplitTree<Ghostty.SurfaceView>.FocusDirection = splitDirection.toSplitTreeFocusDirection()
|
||||||
|
guard controller.surfaceTree.focusTarget(for: focusDirection, from: targetNode) != nil else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// We have a valid target, post the notification to perform the navigation
|
||||||
NotificationCenter.default.post(
|
NotificationCenter.default.post(
|
||||||
name: Notification.ghosttyFocusSplit,
|
name: Notification.ghosttyFocusSplit,
|
||||||
object: surfaceView,
|
object: surfaceView,
|
||||||
userInfo: [
|
userInfo: [
|
||||||
Notification.SplitDirectionKey: SplitFocusDirection.from(direction: direction) as Any,
|
Notification.SplitDirectionKey: splitDirection as Any,
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return true
|
||||||
|
|
||||||
default:
|
default:
|
||||||
assertionFailure()
|
assertionFailure()
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func resizeSplit(
|
private static func resizeSplit(
|
||||||
|
|
|
||||||
|
|
@ -314,17 +314,14 @@ extension Ghostty {
|
||||||
|
|
||||||
var macosCustomIcon: String {
|
var macosCustomIcon: String {
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
let homeDirURL = FileManager.default.homeDirectoryForCurrentUser
|
let defaultValue = NSString("~/.config/ghostty/Ghostty.icns").expandingTildeInPath
|
||||||
let ghosttyConfigIconPath = homeDirURL.appendingPathComponent(
|
|
||||||
".config/ghostty/Ghostty.icns",
|
|
||||||
conformingTo: .fileURL).path()
|
|
||||||
let defaultValue = ghosttyConfigIconPath
|
|
||||||
guard let config = self.config else { return defaultValue }
|
guard let config = self.config else { return defaultValue }
|
||||||
var v: UnsafePointer<Int8>? = nil
|
var v: UnsafePointer<Int8>? = nil
|
||||||
let key = "macos-custom-icon"
|
let key = "macos-custom-icon"
|
||||||
guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue }
|
guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue }
|
||||||
guard let ptr = v else { return defaultValue }
|
guard let ptr = v else { return defaultValue }
|
||||||
return String(cString: ptr)
|
guard let path = NSString(utf8String: ptr) else { return defaultValue }
|
||||||
|
return path.expandingTildeInPath
|
||||||
#else
|
#else
|
||||||
return ""
|
return ""
|
||||||
#endif
|
#endif
|
||||||
|
|
@ -625,6 +622,7 @@ extension Ghostty.Config {
|
||||||
static let audio = BellFeatures(rawValue: 1 << 1)
|
static let audio = BellFeatures(rawValue: 1 << 1)
|
||||||
static let attention = BellFeatures(rawValue: 1 << 2)
|
static let attention = BellFeatures(rawValue: 1 << 2)
|
||||||
static let title = BellFeatures(rawValue: 1 << 3)
|
static let title = BellFeatures(rawValue: 1 << 3)
|
||||||
|
static let border = BellFeatures(rawValue: 1 << 4)
|
||||||
}
|
}
|
||||||
|
|
||||||
enum MacDockDropBehavior: String {
|
enum MacDockDropBehavior: String {
|
||||||
|
|
|
||||||
|
|
@ -223,7 +223,38 @@ extension Ghostty {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if canImport(AppKit)
|
||||||
|
// MARK: SplitFocusDirection Extensions
|
||||||
|
|
||||||
|
extension Ghostty.SplitFocusDirection {
|
||||||
|
/// Convert to a SplitTree.FocusDirection for the given ViewType.
|
||||||
|
func toSplitTreeFocusDirection<ViewType>() -> SplitTree<ViewType>.FocusDirection {
|
||||||
|
switch self {
|
||||||
|
case .previous:
|
||||||
|
return .previous
|
||||||
|
|
||||||
|
case .next:
|
||||||
|
return .next
|
||||||
|
|
||||||
|
case .up:
|
||||||
|
return .spatial(.up)
|
||||||
|
|
||||||
|
case .down:
|
||||||
|
return .spatial(.down)
|
||||||
|
|
||||||
|
case .left:
|
||||||
|
return .spatial(.left)
|
||||||
|
|
||||||
|
case .right:
|
||||||
|
return .spatial(.right)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
extension Ghostty {
|
||||||
/// The type of a clipboard request
|
/// The type of a clipboard request
|
||||||
enum ClipboardRequest {
|
enum ClipboardRequest {
|
||||||
/// A direct paste of clipboard contents
|
/// A direct paste of clipboard contents
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -57,15 +57,6 @@ extension Ghostty {
|
||||||
|
|
||||||
@EnvironmentObject private var ghostty: Ghostty.App
|
@EnvironmentObject private var ghostty: Ghostty.App
|
||||||
|
|
||||||
var title: String {
|
|
||||||
var result = surfaceView.title
|
|
||||||
if (surfaceView.bell && ghostty.config.bellFeatures.contains(.title)) {
|
|
||||||
result = "🔔 \(result)"
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let center = NotificationCenter.default
|
let center = NotificationCenter.default
|
||||||
|
|
||||||
|
|
@ -114,11 +105,17 @@ extension Ghostty {
|
||||||
}
|
}
|
||||||
.ghosttySurfaceView(surfaceView)
|
.ghosttySurfaceView(surfaceView)
|
||||||
|
|
||||||
// Progress report overlay
|
// Progress report
|
||||||
if let progressReport = surfaceView.progressReport {
|
if let progressReport = surfaceView.progressReport, progressReport.state != .remove {
|
||||||
ProgressReportOverlay(report: progressReport)
|
VStack(spacing: 0) {
|
||||||
|
SurfaceProgressBar(report: progressReport)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
.transition(.opacity)
|
||||||
}
|
}
|
||||||
|
|
||||||
#if canImport(AppKit)
|
#if canImport(AppKit)
|
||||||
// If we are in the middle of a key sequence, then we show a visual element. We only
|
// 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!
|
// support this on macOS currently although in theory we can support mobile with keyboards!
|
||||||
|
|
@ -201,6 +198,11 @@ extension Ghostty {
|
||||||
SecureInputOverlay()
|
SecureInputOverlay()
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
// Show bell border if enabled
|
||||||
|
if (ghostty.config.bellFeatures.contains(.border)) {
|
||||||
|
BellBorderOverlay(bell: surfaceView.bell)
|
||||||
|
}
|
||||||
|
|
||||||
// If our surface is not healthy, then we render an error view over it.
|
// If our surface is not healthy, then we render an error view over it.
|
||||||
if (!surfaceView.healthy) {
|
if (!surfaceView.healthy) {
|
||||||
|
|
@ -272,48 +274,7 @@ extension Ghostty {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Progress report overlay that shows a progress bar at the top of the terminal
|
|
||||||
struct ProgressReportOverlay: View {
|
|
||||||
let report: Action.ProgressReport
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var progressBar: some View {
|
|
||||||
if let progress = report.progress {
|
|
||||||
// Determinate progress bar
|
|
||||||
ProgressView(value: Double(progress), total: 100)
|
|
||||||
.progressViewStyle(.linear)
|
|
||||||
.tint(report.state == .error ? .red : report.state == .pause ? .orange : nil)
|
|
||||||
.animation(.easeInOut(duration: 0.2), value: progress)
|
|
||||||
} else {
|
|
||||||
// Indeterminate states
|
|
||||||
switch report.state {
|
|
||||||
case .indeterminate:
|
|
||||||
ProgressView()
|
|
||||||
.progressViewStyle(.linear)
|
|
||||||
case .error:
|
|
||||||
ProgressView()
|
|
||||||
.progressViewStyle(.linear)
|
|
||||||
.tint(.red)
|
|
||||||
case .pause:
|
|
||||||
Rectangle().fill(Color.orange)
|
|
||||||
default:
|
|
||||||
EmptyView()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
progressBar
|
|
||||||
.scaleEffect(x: 1, y: 0.5, anchor: .center)
|
|
||||||
.frame(height: 2)
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
|
||||||
.allowsHitTesting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// This is the resize overlay that shows on top of a surface to show the current
|
// This is the resize overlay that shows on top of a surface to show the current
|
||||||
// size during a resize operation.
|
// size during a resize operation.
|
||||||
|
|
@ -570,6 +531,22 @@ extension Ghostty {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Visual overlay that shows a border around the edges when the bell rings with border feature enabled.
|
||||||
|
struct BellBorderOverlay: View {
|
||||||
|
let bell: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Rectangle()
|
||||||
|
.strokeBorder(
|
||||||
|
Color(red: 1.0, green: 0.8, blue: 0.0).opacity(0.5),
|
||||||
|
lineWidth: 3
|
||||||
|
)
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
.opacity(bell ? 1.0 : 0.0)
|
||||||
|
.animation(.easeInOut(duration: 0.3), value: bell)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#if canImport(AppKit)
|
#if canImport(AppKit)
|
||||||
/// When changing the split state, or going full screen (native or non), the terminal view
|
/// When changing the split state, or going full screen (native or non), the terminal view
|
||||||
/// will lose focus. There has to be some nice SwiftUI-native way to fix this but I can't
|
/// will lose focus. There has to be some nice SwiftUI-native way to fix this but I can't
|
||||||
|
|
|
||||||
|
|
@ -1815,18 +1815,39 @@ extension Ghostty.SurfaceView: NSServicesMenuRequestor {
|
||||||
forSendType sendType: NSPasteboard.PasteboardType?,
|
forSendType sendType: NSPasteboard.PasteboardType?,
|
||||||
returnType: NSPasteboard.PasteboardType?
|
returnType: NSPasteboard.PasteboardType?
|
||||||
) -> Any? {
|
) -> Any? {
|
||||||
// Types that we accept sent to us
|
// This function confused me a bit so I'm going to add my own commentary on
|
||||||
let accepted: [NSPasteboard.PasteboardType] = [.string, .init("public.utf8-plain-text")]
|
// how this works. macOS sends this callback with the given send/return types and
|
||||||
|
// we must return the responder capable of handling the COMBINATION of those send
|
||||||
|
// and return types (or super up if we can't handle it).
|
||||||
|
//
|
||||||
|
// The "COMBINATION" bit is key: we might get sent a string (we can handle that)
|
||||||
|
// but get requested an image (we can't handle that at the time of writing this),
|
||||||
|
// so we must bubble up.
|
||||||
|
|
||||||
|
// 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 we expect no data to be sent/received we can obviously handle it (that's
|
||||||
if (returnType == nil || accepted.contains(returnType!)) {
|
// the nil check), otherwise it must conform to the types we support on both sides.
|
||||||
return self
|
if (returnType == nil || receivable.contains(returnType!)) &&
|
||||||
}
|
(sendType == nil || sendable.contains(sendType!)) {
|
||||||
|
// If we're expected to send back a type that requires selection, then
|
||||||
// If we have a selection we can send the accepted types too
|
// verify that we have a selection. We do this within this block because
|
||||||
if ((self.surface != nil && ghostty_surface_has_selection(self.surface)) &&
|
// validateRequestor is called a LOT and we want to prevent unnecessary
|
||||||
(sendType == nil || accepted.contains(sendType!))
|
// 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
|
return self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,13 @@ extension NSScreen {
|
||||||
var displayID: UInt32? {
|
var displayID: UInt32? {
|
||||||
deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? UInt32
|
deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? UInt32
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The stable UUID for this display, suitable for tracking across reconnects and NSScreen garbage collection.
|
||||||
|
var displayUUID: UUID? {
|
||||||
|
guard let displayID = displayID else { return nil }
|
||||||
|
guard let cfuuid = CGDisplayCreateUUIDFromDisplayID(displayID)?.takeRetainedValue() else { return nil }
|
||||||
|
return UUID(cfuuid)
|
||||||
|
}
|
||||||
|
|
||||||
// Returns true if the given screen has a visible dock. This isn't
|
// Returns true if the given screen has a visible dock. This isn't
|
||||||
// point-in-time visible, this is true if the dock is always visible
|
// point-in-time visible, this is true if the dock is always visible
|
||||||
|
|
@ -41,4 +48,20 @@ extension NSScreen {
|
||||||
// know any other situation this is true.
|
// know any other situation this is true.
|
||||||
return safeAreaInsets.top > 0
|
return safeAreaInsets.top > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Converts top-left offset coordinates to bottom-left origin coordinates for window positioning.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - x: X offset from top-left corner
|
||||||
|
/// - y: Y offset from top-left corner
|
||||||
|
/// - windowSize: Size of the window to be positioned
|
||||||
|
/// - Returns: CGPoint suitable for setFrameOrigin that positions the window as requested
|
||||||
|
func origin(fromTopLeftOffsetX x: CGFloat, offsetY y: CGFloat, windowSize: CGSize) -> CGPoint {
|
||||||
|
let vf = visibleFrame
|
||||||
|
|
||||||
|
// Convert top-left coordinates to bottom-left origin
|
||||||
|
let originX = vf.minX + x
|
||||||
|
let originY = vf.maxY - y - windowSize.height
|
||||||
|
|
||||||
|
return CGPoint(x: originX, y: originY)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension UUID {
|
||||||
|
/// Initialize a UUID from a CFUUID.
|
||||||
|
init?(_ cfuuid: CFUUID) {
|
||||||
|
guard let uuidString = CFUUIDCreateString(nil, cfuuid) as String? else { return nil }
|
||||||
|
self.init(uuidString: uuidString)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -40,7 +40,7 @@
|
||||||
in
|
in
|
||||||
stdenv.mkDerivation (finalAttrs: {
|
stdenv.mkDerivation (finalAttrs: {
|
||||||
pname = "ghostty";
|
pname = "ghostty";
|
||||||
version = "1.2.0";
|
version = "1.2.3";
|
||||||
|
|
||||||
# We limit source like this to try and reduce the amount of rebuilds as possible
|
# We limit source like this to try and reduce the amount of rebuilds as possible
|
||||||
# thus we only provide the source that is needed for the build
|
# thus we only provide the source that is needed for the build
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ pub const Library = @import("Library.zig");
|
||||||
|
|
||||||
pub const Error = errors.Error;
|
pub const Error = errors.Error;
|
||||||
pub const Face = face.Face;
|
pub const Face = face.Face;
|
||||||
|
pub const LoadFlags = face.LoadFlags;
|
||||||
pub const Tag = tag.Tag;
|
pub const Tag = tag.Tag;
|
||||||
pub const mulFix = computations.mulFix;
|
pub const mulFix = computations.mulFix;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ const font_descriptor = @import("text/font_descriptor.zig");
|
||||||
const font_manager = @import("text/font_manager.zig");
|
const font_manager = @import("text/font_manager.zig");
|
||||||
const frame = @import("text/frame.zig");
|
const frame = @import("text/frame.zig");
|
||||||
const framesetter = @import("text/framesetter.zig");
|
const framesetter = @import("text/framesetter.zig");
|
||||||
|
const typesetter = @import("text/typesetter.zig");
|
||||||
const line = @import("text/line.zig");
|
const line = @import("text/line.zig");
|
||||||
const paragraph_style = @import("text/paragraph_style.zig");
|
const paragraph_style = @import("text/paragraph_style.zig");
|
||||||
const run = @import("text/run.zig");
|
const run = @import("text/run.zig");
|
||||||
|
|
@ -23,6 +24,7 @@ pub const createFontDescriptorsFromData = font_manager.createFontDescriptorsFrom
|
||||||
pub const createFontDescriptorFromData = font_manager.createFontDescriptorFromData;
|
pub const createFontDescriptorFromData = font_manager.createFontDescriptorFromData;
|
||||||
pub const Frame = frame.Frame;
|
pub const Frame = frame.Frame;
|
||||||
pub const Framesetter = framesetter.Framesetter;
|
pub const Framesetter = framesetter.Framesetter;
|
||||||
|
pub const Typesetter = typesetter.Typesetter;
|
||||||
pub const Line = line.Line;
|
pub const Line = line.Line;
|
||||||
pub const ParagraphStyle = paragraph_style.ParagraphStyle;
|
pub const ParagraphStyle = paragraph_style.ParagraphStyle;
|
||||||
pub const ParagraphStyleSetting = paragraph_style.ParagraphStyleSetting;
|
pub const ParagraphStyleSetting = paragraph_style.ParagraphStyleSetting;
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,13 @@ pub const Run = opaque {
|
||||||
return @intCast(c.CTRunGetGlyphCount(@ptrCast(self)));
|
return @intCast(c.CTRunGetGlyphCount(@ptrCast(self)));
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn getGlyphsPtr(self: *Run) []const graphics.Glyph {
|
pub fn getGlyphsPtr(self: *Run) ?[]const graphics.Glyph {
|
||||||
const len = self.getGlyphCount();
|
const len = self.getGlyphCount();
|
||||||
if (len == 0) return &.{};
|
if (len == 0) return &.{};
|
||||||
const ptr = c.CTRunGetGlyphsPtr(@ptrCast(self)) orelse &.{};
|
const ptr: [*c]const graphics.Glyph = @ptrCast(
|
||||||
|
c.CTRunGetGlyphsPtr(@ptrCast(self)),
|
||||||
|
);
|
||||||
|
if (ptr == null) return null;
|
||||||
return ptr[0..len];
|
return ptr[0..len];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -34,10 +37,13 @@ pub const Run = opaque {
|
||||||
return ptr;
|
return ptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn getPositionsPtr(self: *Run) []const graphics.Point {
|
pub fn getPositionsPtr(self: *Run) ?[]const graphics.Point {
|
||||||
const len = self.getGlyphCount();
|
const len = self.getGlyphCount();
|
||||||
if (len == 0) return &.{};
|
if (len == 0) return &.{};
|
||||||
const ptr = c.CTRunGetPositionsPtr(@ptrCast(self)) orelse &.{};
|
const ptr: [*c]const graphics.Point = @ptrCast(
|
||||||
|
c.CTRunGetPositionsPtr(@ptrCast(self)),
|
||||||
|
);
|
||||||
|
if (ptr == null) return null;
|
||||||
return ptr[0..len];
|
return ptr[0..len];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -53,10 +59,13 @@ pub const Run = opaque {
|
||||||
return ptr;
|
return ptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn getAdvancesPtr(self: *Run) []const graphics.Size {
|
pub fn getAdvancesPtr(self: *Run) ?[]const graphics.Size {
|
||||||
const len = self.getGlyphCount();
|
const len = self.getGlyphCount();
|
||||||
if (len == 0) return &.{};
|
if (len == 0) return &.{};
|
||||||
const ptr = c.CTRunGetAdvancesPtr(@ptrCast(self)) orelse &.{};
|
const ptr: [*c]const graphics.Size = @ptrCast(
|
||||||
|
c.CTRunGetAdvancesPtr(@ptrCast(self)),
|
||||||
|
);
|
||||||
|
if (ptr == null) return null;
|
||||||
return ptr[0..len];
|
return ptr[0..len];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -72,10 +81,13 @@ pub const Run = opaque {
|
||||||
return ptr;
|
return ptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn getStringIndicesPtr(self: *Run) []const usize {
|
pub fn getStringIndicesPtr(self: *Run) ?[]const usize {
|
||||||
const len = self.getGlyphCount();
|
const len = self.getGlyphCount();
|
||||||
if (len == 0) return &.{};
|
if (len == 0) return &.{};
|
||||||
const ptr = c.CTRunGetStringIndicesPtr(@ptrCast(self)) orelse &.{};
|
const ptr: [*c]const usize = @ptrCast(
|
||||||
|
c.CTRunGetStringIndicesPtr(@ptrCast(self)),
|
||||||
|
);
|
||||||
|
if (ptr == null) return null;
|
||||||
return ptr[0..len];
|
return ptr[0..len];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -90,4 +102,16 @@ pub const Run = opaque {
|
||||||
);
|
);
|
||||||
return ptr;
|
return ptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn getStatus(self: *Run) Status {
|
||||||
|
return @bitCast(c.CTRunGetStatus(@ptrCast(self)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/// https://developer.apple.com/documentation/coretext/ctrunstatus?language=objc
|
||||||
|
pub const Status = packed struct(u32) {
|
||||||
|
right_to_left: bool,
|
||||||
|
non_monotonic: bool,
|
||||||
|
has_non_identity_matrix: bool,
|
||||||
|
_pad: u29 = 0,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
const std = @import("std");
|
||||||
|
const assert = std.debug.assert;
|
||||||
|
const Allocator = std.mem.Allocator;
|
||||||
|
const foundation = @import("../foundation.zig");
|
||||||
|
const graphics = @import("../graphics.zig");
|
||||||
|
const text = @import("../text.zig");
|
||||||
|
const c = @import("c.zig").c;
|
||||||
|
|
||||||
|
pub const Typesetter = opaque {
|
||||||
|
pub fn createWithAttributedStringAndOptions(
|
||||||
|
str: *foundation.AttributedString,
|
||||||
|
opts: *foundation.Dictionary,
|
||||||
|
) Allocator.Error!*Typesetter {
|
||||||
|
return @as(
|
||||||
|
?*Typesetter,
|
||||||
|
@ptrFromInt(@intFromPtr(c.CTTypesetterCreateWithAttributedStringAndOptions(
|
||||||
|
@ptrCast(str),
|
||||||
|
@ptrCast(opts),
|
||||||
|
))),
|
||||||
|
) orelse Allocator.Error.OutOfMemory;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn release(self: *Typesetter) void {
|
||||||
|
foundation.CFRelease(self);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn createLine(
|
||||||
|
self: *Typesetter,
|
||||||
|
range: foundation.c.CFRange,
|
||||||
|
) *text.Line {
|
||||||
|
return @ptrFromInt(@intFromPtr(c.CTTypesetterCreateLine(
|
||||||
|
@ptrCast(self),
|
||||||
|
range,
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,7 @@ pub const Buffer = @import("Buffer.zig");
|
||||||
pub const Framebuffer = @import("Framebuffer.zig");
|
pub const Framebuffer = @import("Framebuffer.zig");
|
||||||
pub const Renderbuffer = @import("Renderbuffer.zig");
|
pub const Renderbuffer = @import("Renderbuffer.zig");
|
||||||
pub const Program = @import("Program.zig");
|
pub const Program = @import("Program.zig");
|
||||||
|
pub const Sampler = @import("Sampler.zig");
|
||||||
pub const Shader = @import("Shader.zig");
|
pub const Shader = @import("Shader.zig");
|
||||||
pub const Texture = @import("Texture.zig");
|
pub const Texture = @import("Texture.zig");
|
||||||
pub const VertexArray = @import("VertexArray.zig");
|
pub const VertexArray = @import("VertexArray.zig");
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -4,14 +4,15 @@
|
||||||
# This file is distributed under the same license as the com.mitchellh.ghostty package.
|
# This file is distributed under the same license as the com.mitchellh.ghostty package.
|
||||||
# Gustavo Peres <gsodevel@gmail.com>, 2025.
|
# Gustavo Peres <gsodevel@gmail.com>, 2025.
|
||||||
# Guilherme Tiscoski <github@guilhermetiscoski.com>, 2025.
|
# Guilherme Tiscoski <github@guilhermetiscoski.com>, 2025.
|
||||||
|
# Nilton Perim Neto <niltonperimneto@gmail.com>, 2025.
|
||||||
#
|
#
|
||||||
msgid ""
|
msgid ""
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: com.mitchellh.ghostty\n"
|
"Project-Id-Version: com.mitchellh.ghostty\n"
|
||||||
"Report-Msgid-Bugs-To: m@mitchellh.com\n"
|
"Report-Msgid-Bugs-To: m@mitchellh.com\n"
|
||||||
"POT-Creation-Date: 2025-07-22 17:18+0000\n"
|
"POT-Creation-Date: 2025-07-22 17:18+0000\n"
|
||||||
"PO-Revision-Date: 2025-08-25 11:46-0500\n"
|
"PO-Revision-Date: 2025-09-15 13:57-0300\n"
|
||||||
"Last-Translator: Guilherme Tiscoski <github@guihermetiscoski.com>\n"
|
"Last-Translator: Nilton Perim Neto <niltonperimneto@gmail.com>\n"
|
||||||
"Language-Team: Brazilian Portuguese <ldpbr-translation@lists.sourceforge."
|
"Language-Team: Brazilian Portuguese <ldpbr-translation@lists.sourceforge."
|
||||||
"net>\n"
|
"net>\n"
|
||||||
"Language: pt_BR\n"
|
"Language: pt_BR\n"
|
||||||
|
|
@ -26,7 +27,7 @@ msgstr "Mudar título do Terminal"
|
||||||
|
|
||||||
#: src/apprt/gtk/ui/1.5/prompt-title-dialog.blp:6
|
#: src/apprt/gtk/ui/1.5/prompt-title-dialog.blp:6
|
||||||
msgid "Leave blank to restore the default title."
|
msgid "Leave blank to restore the default title."
|
||||||
msgstr "Deixe em branco para restaurar o título original."
|
msgstr "Deixe em branco para restaurar o título padrão."
|
||||||
|
|
||||||
#: src/apprt/gtk/ui/1.5/prompt-title-dialog.blp:9
|
#: src/apprt/gtk/ui/1.5/prompt-title-dialog.blp:9
|
||||||
#: src/apprt/gtk/ui/1.5/ccw-paste.blp:10 src/apprt/gtk/ui/1.2/ccw-paste.blp:10
|
#: src/apprt/gtk/ui/1.5/ccw-paste.blp:10 src/apprt/gtk/ui/1.2/ccw-paste.blp:10
|
||||||
|
|
@ -315,8 +316,8 @@ msgstr "Configuração recarregada"
|
||||||
|
|
||||||
#: src/apprt/gtk/Window.zig:1019
|
#: src/apprt/gtk/Window.zig:1019
|
||||||
msgid "Ghostty Developers"
|
msgid "Ghostty Developers"
|
||||||
msgstr "Desenvolvedores Ghostty"
|
msgstr "Desenvolvedores do Ghostty"
|
||||||
|
|
||||||
#: src/apprt/gtk/inspector.zig:144
|
#: src/apprt/gtk/inspector.zig:144
|
||||||
msgid "Ghostty: Terminal Inspector"
|
msgid "Ghostty: Terminal Inspector"
|
||||||
msgstr "Ghostty: Inspetor de terminal"
|
msgstr "Ghostty: Inspetor do terminal"
|
||||||
|
|
|
||||||
|
|
@ -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:終端機檢查工具"
|
||||||
|
|
@ -61,10 +61,4 @@ fi
|
||||||
|
|
||||||
[ "$needs_update" = true ] && echo "LAST_REVISION=$SNAP_REVISION" > "$SNAP_USER_DATA/.last_revision"
|
[ "$needs_update" = true ] && echo "LAST_REVISION=$SNAP_REVISION" > "$SNAP_USER_DATA/.last_revision"
|
||||||
|
|
||||||
# Unset all SNAP specific environment variables to keep them from leaking
|
|
||||||
# into other snaps that might get executed from within the shell
|
|
||||||
for var in $(printenv | grep SNAP_ | cut -d= -f1); do
|
|
||||||
unset $var
|
|
||||||
done
|
|
||||||
|
|
||||||
exec "$@"
|
exec "$@"
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ platforms:
|
||||||
apps:
|
apps:
|
||||||
ghostty:
|
ghostty:
|
||||||
command: bin/ghostty
|
command: bin/ghostty
|
||||||
command-chain: [bin/launcher]
|
command-chain: [app/launcher]
|
||||||
completer: share/bash-completion/completions/ghostty.bash
|
completer: share/bash-completion/completions/ghostty.bash
|
||||||
desktop: share/applications/com.mitchellh.ghostty.desktop
|
desktop: share/applications/com.mitchellh.ghostty.desktop
|
||||||
#refresh-mode: ignore-running # Store rejects this, needs fix in review-tools
|
#refresh-mode: ignore-running # Store rejects this, needs fix in review-tools
|
||||||
|
|
@ -35,7 +35,7 @@ parts:
|
||||||
source: snap/local
|
source: snap/local
|
||||||
source-type: local
|
source-type: local
|
||||||
organize:
|
organize:
|
||||||
launcher: bin/
|
launcher: app/
|
||||||
|
|
||||||
zig:
|
zig:
|
||||||
plugin: nil
|
plugin: nil
|
||||||
|
|
@ -79,7 +79,12 @@ parts:
|
||||||
# TODO: Remove -fno-sys=gtk4-layer-shell when we upgrade to a version that packages it Ubuntu 24.10+
|
# TODO: Remove -fno-sys=gtk4-layer-shell when we upgrade to a version that packages it Ubuntu 24.10+
|
||||||
override-build: |
|
override-build: |
|
||||||
craftctl set version=$(cat VERSION)
|
craftctl set version=$(cat VERSION)
|
||||||
$CRAFT_PART_SRC/../../zig/src/zig build -Dpatch-rpath=\$ORIGIN/../usr/lib/$CRAFT_ARCH_TRIPLET_BUILD_FOR:/snap/core24/current/lib/$CRAFT_ARCH_TRIPLET_BUILD_FOR -Doptimize=ReleaseFast -Dcpu=baseline -fno-sys=gtk4-layer-shell
|
$CRAFT_PART_SRC/../../zig/src/zig build \
|
||||||
|
-Dsnap \
|
||||||
|
-Dpatch-rpath=\$ORIGIN/../usr/lib/$CRAFT_ARCH_TRIPLET_BUILD_FOR:/snap/core24/current/lib/$CRAFT_ARCH_TRIPLET_BUILD_FOR \
|
||||||
|
-Doptimize=ReleaseFast \
|
||||||
|
-Dcpu=baseline \
|
||||||
|
-fno-sys=gtk4-layer-shell
|
||||||
cp -rp zig-out/* $CRAFT_PART_INSTALL/
|
cp -rp zig-out/* $CRAFT_PART_INSTALL/
|
||||||
sed -i 's|Icon=com.mitchellh.ghostty|Icon=${SNAP}/share/icons/hicolor/512x512/apps/com.mitchellh.ghostty.png|g' $CRAFT_PART_INSTALL/share/applications/com.mitchellh.ghostty.desktop
|
sed -i 's|Icon=com.mitchellh.ghostty|Icon=${SNAP}/share/icons/hicolor/512x512/apps/com.mitchellh.ghostty.png|g' $CRAFT_PART_INSTALL/share/applications/com.mitchellh.ghostty.desktop
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,12 @@ font_grid_key: font.SharedGridSet.Key,
|
||||||
font_size: font.face.DesiredSize,
|
font_size: font.face.DesiredSize,
|
||||||
font_metrics: font.Metrics,
|
font_metrics: font.Metrics,
|
||||||
|
|
||||||
|
/// This keeps track of if the font size was ever modified. If it wasn't,
|
||||||
|
/// then config reloading will change the font. If it was manually adjusted,
|
||||||
|
/// we don't change it on config reload since we assume the user wants
|
||||||
|
/// a specific size.
|
||||||
|
font_size_adjusted: bool,
|
||||||
|
|
||||||
/// The renderer for this surface.
|
/// The renderer for this surface.
|
||||||
renderer: Renderer,
|
renderer: Renderer,
|
||||||
|
|
||||||
|
|
@ -254,7 +260,7 @@ const DerivedConfig = struct {
|
||||||
font: font.SharedGridSet.DerivedConfig,
|
font: font.SharedGridSet.DerivedConfig,
|
||||||
mouse_interval: u64,
|
mouse_interval: u64,
|
||||||
mouse_hide_while_typing: bool,
|
mouse_hide_while_typing: bool,
|
||||||
mouse_scroll_multiplier: f64,
|
mouse_scroll_multiplier: configpkg.MouseScrollMultiplier,
|
||||||
mouse_shift_capture: configpkg.MouseShiftCapture,
|
mouse_shift_capture: configpkg.MouseShiftCapture,
|
||||||
macos_non_native_fullscreen: configpkg.NonNativeFullscreen,
|
macos_non_native_fullscreen: configpkg.NonNativeFullscreen,
|
||||||
macos_option_as_alt: ?configpkg.OptionAsAlt,
|
macos_option_as_alt: ?configpkg.OptionAsAlt,
|
||||||
|
|
@ -514,6 +520,7 @@ pub fn init(
|
||||||
.rt_surface = rt_surface,
|
.rt_surface = rt_surface,
|
||||||
.font_grid_key = font_grid_key,
|
.font_grid_key = font_grid_key,
|
||||||
.font_size = font_size,
|
.font_size = font_size,
|
||||||
|
.font_size_adjusted = false,
|
||||||
.font_metrics = font_grid.metrics,
|
.font_metrics = font_grid.metrics,
|
||||||
.renderer = renderer_impl,
|
.renderer = renderer_impl,
|
||||||
.renderer_thread = render_thread,
|
.renderer_thread = render_thread,
|
||||||
|
|
@ -997,6 +1004,16 @@ fn selectionScrollTick(self: *Surface) !void {
|
||||||
defer self.renderer_state.mutex.unlock();
|
defer self.renderer_state.mutex.unlock();
|
||||||
const t: *terminal.Terminal = self.renderer_state.terminal;
|
const t: *terminal.Terminal = self.renderer_state.terminal;
|
||||||
|
|
||||||
|
// If our screen changed while this is happening, we stop our
|
||||||
|
// selection scroll.
|
||||||
|
if (self.mouse.left_click_screen != t.active_screen) {
|
||||||
|
self.io.queueMessage(
|
||||||
|
.{ .selection_scroll = false },
|
||||||
|
.locked,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Scroll the viewport as required
|
// Scroll the viewport as required
|
||||||
try t.scrollViewport(.{ .delta = delta });
|
try t.scrollViewport(.{ .delta = delta });
|
||||||
|
|
||||||
|
|
@ -1446,7 +1463,21 @@ pub fn updateConfig(
|
||||||
// but this is easier and pretty rare so it's not a performance concern.
|
// but this is easier and pretty rare so it's not a performance concern.
|
||||||
//
|
//
|
||||||
// (Calling setFontSize builds and sends a new font grid to the renderer.)
|
// (Calling setFontSize builds and sends a new font grid to the renderer.)
|
||||||
try self.setFontSize(self.font_size);
|
try self.setFontSize(font_size: {
|
||||||
|
// If we have manually adjusted the font size, keep it that way.
|
||||||
|
if (self.font_size_adjusted) {
|
||||||
|
log.info("font size manually adjusted, preserving previous size on config reload", .{});
|
||||||
|
break :font_size self.font_size;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we haven't, then we update to the configured font size.
|
||||||
|
// This allows config changes to update the font size. We used to
|
||||||
|
// never do this but it was a common source of confusion and people
|
||||||
|
// assumed that Ghostty was broken! This logic makes more sense.
|
||||||
|
var size = self.font_size;
|
||||||
|
size.points = std.math.clamp(config.@"font-size", 1.0, 255.0);
|
||||||
|
break :font_size size;
|
||||||
|
});
|
||||||
|
|
||||||
// We need to store our configs in a heap-allocated pointer so that
|
// We need to store our configs in a heap-allocated pointer so that
|
||||||
// our messages aren't huge.
|
// our messages aren't huge.
|
||||||
|
|
@ -2808,7 +2839,7 @@ pub fn scrollCallback(
|
||||||
// scroll events to pixels by multiplying the wheel tick value and the cell size. This means
|
// scroll events to pixels by multiplying the wheel tick value and the cell size. This means
|
||||||
// that a wheel tick of 1 results in single scroll event.
|
// that a wheel tick of 1 results in single scroll event.
|
||||||
const yoff_adjusted: f64 = if (scroll_mods.precision)
|
const yoff_adjusted: f64 = if (scroll_mods.precision)
|
||||||
yoff
|
yoff * self.config.mouse_scroll_multiplier.precision
|
||||||
else yoff_adjusted: {
|
else yoff_adjusted: {
|
||||||
// Round out the yoff to an absolute minimum of 1. macos tries to
|
// Round out the yoff to an absolute minimum of 1. macos tries to
|
||||||
// simulate precision scrolling with non precision events by
|
// simulate precision scrolling with non precision events by
|
||||||
|
|
@ -2822,7 +2853,7 @@ pub fn scrollCallback(
|
||||||
else
|
else
|
||||||
@min(yoff, -1);
|
@min(yoff, -1);
|
||||||
|
|
||||||
break :yoff_adjusted yoff_max * cell_size * self.config.mouse_scroll_multiplier;
|
break :yoff_adjusted yoff_max * cell_size * self.config.mouse_scroll_multiplier.discrete;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add our previously saved pending amount to the offset to get the
|
// Add our previously saved pending amount to the offset to get the
|
||||||
|
|
@ -3991,7 +4022,7 @@ pub fn cursorPosCallback(
|
||||||
|
|
||||||
// Stop selection scrolling when inside the viewport within a 1px buffer
|
// Stop selection scrolling when inside the viewport within a 1px buffer
|
||||||
// for fullscreen windows, but only when selection scrolling is active.
|
// for fullscreen windows, but only when selection scrolling is active.
|
||||||
if (pos.x >= 1 and pos.y >= 1 and self.selection_scroll_active) {
|
if (pos.y >= 1 and self.selection_scroll_active) {
|
||||||
self.io.queueMessage(
|
self.io.queueMessage(
|
||||||
.{ .selection_scroll = false },
|
.{ .selection_scroll = false },
|
||||||
.locked,
|
.locked,
|
||||||
|
|
@ -4070,6 +4101,12 @@ pub fn cursorPosCallback(
|
||||||
// count because we don't want to handle selection.
|
// count because we don't want to handle selection.
|
||||||
if (self.mouse.left_click_count == 0) break :select;
|
if (self.mouse.left_click_count == 0) break :select;
|
||||||
|
|
||||||
|
// If our terminal screen changed then we don't process this. We don't
|
||||||
|
// invalidate our pin or mouse state because if the screen switches
|
||||||
|
// back then we can continue our selection.
|
||||||
|
const t: *terminal.Terminal = self.renderer_state.terminal;
|
||||||
|
if (self.mouse.left_click_screen != t.active_screen) break :select;
|
||||||
|
|
||||||
// All roads lead to requiring a re-render at this point.
|
// All roads lead to requiring a re-render at this point.
|
||||||
try self.queueRender();
|
try self.queueRender();
|
||||||
|
|
||||||
|
|
@ -4093,7 +4130,7 @@ pub fn cursorPosCallback(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert to points
|
// Convert to points
|
||||||
const screen = &self.renderer_state.terminal.screen;
|
const screen = &t.screen;
|
||||||
const pin = screen.pages.pin(.{
|
const pin = screen.pages.pin(.{
|
||||||
.viewport = .{
|
.viewport = .{
|
||||||
.x = pos_vp.x,
|
.x = pos_vp.x,
|
||||||
|
|
@ -4637,10 +4674,13 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
|
||||||
|
|
||||||
log.debug("increase font size={}", .{clamped_delta});
|
log.debug("increase font size={}", .{clamped_delta});
|
||||||
|
|
||||||
var size = self.font_size;
|
|
||||||
// Max point size is somewhat arbitrary.
|
// Max point size is somewhat arbitrary.
|
||||||
|
var size = self.font_size;
|
||||||
size.points = @min(size.points + clamped_delta, 255);
|
size.points = @min(size.points + clamped_delta, 255);
|
||||||
try self.setFontSize(size);
|
try self.setFontSize(size);
|
||||||
|
|
||||||
|
// Mark that we manually adjusted the font size
|
||||||
|
self.font_size_adjusted = true;
|
||||||
},
|
},
|
||||||
|
|
||||||
.decrease_font_size => |delta| {
|
.decrease_font_size => |delta| {
|
||||||
|
|
@ -4652,6 +4692,9 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
|
||||||
var size = self.font_size;
|
var size = self.font_size;
|
||||||
size.points = @max(1, size.points - clamped_delta);
|
size.points = @max(1, size.points - clamped_delta);
|
||||||
try self.setFontSize(size);
|
try self.setFontSize(size);
|
||||||
|
|
||||||
|
// Mark that we manually adjusted the font size
|
||||||
|
self.font_size_adjusted = true;
|
||||||
},
|
},
|
||||||
|
|
||||||
.reset_font_size => {
|
.reset_font_size => {
|
||||||
|
|
@ -4660,6 +4703,9 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
|
||||||
var size = self.font_size;
|
var size = self.font_size;
|
||||||
size.points = self.config.original_font_size;
|
size.points = self.config.original_font_size;
|
||||||
try self.setFontSize(size);
|
try self.setFontSize(size);
|
||||||
|
|
||||||
|
// Reset font size also resets the manual adjustment state
|
||||||
|
self.font_size_adjusted = false;
|
||||||
},
|
},
|
||||||
|
|
||||||
.set_font_size => |points| {
|
.set_font_size => |points| {
|
||||||
|
|
@ -4668,6 +4714,9 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
|
||||||
var size = self.font_size;
|
var size = self.font_size;
|
||||||
size.points = std.math.clamp(points, 1.0, 255.0);
|
size.points = std.math.clamp(points, 1.0, 255.0);
|
||||||
try self.setFontSize(size);
|
try self.setFontSize(size);
|
||||||
|
|
||||||
|
// Mark that we manually adjusted the font size
|
||||||
|
self.font_size_adjusted = true;
|
||||||
},
|
},
|
||||||
|
|
||||||
.prompt_surface_title => return try self.rt_app.performAction(
|
.prompt_surface_title => return try self.rt_app.performAction(
|
||||||
|
|
|
||||||
|
|
@ -569,6 +569,15 @@ pub const SetTitle = struct {
|
||||||
.title = self.title.ptr,
|
.title = self.title.ptr,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn format(
|
||||||
|
value: @This(),
|
||||||
|
comptime _: []const u8,
|
||||||
|
_: std.fmt.FormatOptions,
|
||||||
|
writer: anytype,
|
||||||
|
) !void {
|
||||||
|
try writer.print("{s}{{ {s} }}", .{ @typeName(@This()), value.title });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const Pwd = struct {
|
pub const Pwd = struct {
|
||||||
|
|
@ -584,6 +593,15 @@ pub const Pwd = struct {
|
||||||
.pwd = self.pwd.ptr,
|
.pwd = self.pwd.ptr,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn format(
|
||||||
|
value: @This(),
|
||||||
|
comptime _: []const u8,
|
||||||
|
_: std.fmt.FormatOptions,
|
||||||
|
writer: anytype,
|
||||||
|
) !void {
|
||||||
|
try writer.print("{s}{{ {s} }}", .{ @typeName(@This()), value.pwd });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/// The desktop notification to show.
|
/// The desktop notification to show.
|
||||||
|
|
@ -603,6 +621,19 @@ pub const DesktopNotification = struct {
|
||||||
.body = self.body.ptr,
|
.body = self.body.ptr,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn format(
|
||||||
|
value: @This(),
|
||||||
|
comptime _: []const u8,
|
||||||
|
_: std.fmt.FormatOptions,
|
||||||
|
writer: anytype,
|
||||||
|
) !void {
|
||||||
|
try writer.print("{s}{{ title: {s}, body: {s} }}", .{
|
||||||
|
@typeName(@This()),
|
||||||
|
value.title,
|
||||||
|
value.body,
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const KeySequence = union(enum) {
|
pub const KeySequence = union(enum) {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ const internal_os = @import("../os/main.zig");
|
||||||
// The required comptime API for any apprt.
|
// The required comptime API for any apprt.
|
||||||
pub const App = @import("gtk/App.zig");
|
pub const App = @import("gtk/App.zig");
|
||||||
pub const Surface = @import("gtk/Surface.zig");
|
pub const Surface = @import("gtk/Surface.zig");
|
||||||
pub const resourcesDir = internal_os.resourcesDir;
|
pub const resourcesDir = @import("gtk/flatpak.zig").resourcesDir;
|
||||||
|
|
||||||
// The exported API, custom for the apprt.
|
// The exported API, custom for the apprt.
|
||||||
pub const class = @import("gtk/class.zig");
|
pub const class = @import("gtk/class.zig");
|
||||||
|
|
|
||||||
|
|
@ -456,13 +456,23 @@ pub const Application = extern struct {
|
||||||
if (!config.@"quit-after-last-window-closed") break :q false;
|
if (!config.@"quit-after-last-window-closed") break :q false;
|
||||||
|
|
||||||
// If the quit timer has expired, quit.
|
// If the quit timer has expired, quit.
|
||||||
if (priv.quit_timer == .expired) break :q true;
|
if (priv.quit_timer == .expired) {
|
||||||
|
log.debug("must_quit due to quit timer expired", .{});
|
||||||
|
break :q true;
|
||||||
|
}
|
||||||
|
|
||||||
// If we have no windows attached to our app, also quit.
|
// If we have no windows attached to our app, also quit.
|
||||||
if (priv.requested_window and @as(
|
// We only do this if we don't have the closed delay set,
|
||||||
?*glib.List,
|
// because with the closed delay set we'll exit eventually.
|
||||||
self.as(gtk.Application).getWindows(),
|
if (config.@"quit-after-last-window-closed-delay" == null) {
|
||||||
) == null) break :q true;
|
if (priv.requested_window and @as(
|
||||||
|
?*glib.List,
|
||||||
|
self.as(gtk.Application).getWindows(),
|
||||||
|
) == null) {
|
||||||
|
log.debug("must_quit due to no app windows", .{});
|
||||||
|
break :q true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// No quit conditions met
|
// No quit conditions met
|
||||||
break :q false;
|
break :q false;
|
||||||
|
|
@ -741,6 +751,10 @@ pub const Application = extern struct {
|
||||||
|
|
||||||
const writer = buf.writer(alloc);
|
const writer = buf.writer(alloc);
|
||||||
|
|
||||||
|
// Load standard css first as it can override some of the user configured styling.
|
||||||
|
try loadRuntimeCss414(config, &writer);
|
||||||
|
try loadRuntimeCss416(config, &writer);
|
||||||
|
|
||||||
const unfocused_fill: CoreConfig.Color = config.@"unfocused-split-fill" orelse config.background;
|
const unfocused_fill: CoreConfig.Color = config.@"unfocused-split-fill" orelse config.background;
|
||||||
|
|
||||||
try writer.print(
|
try writer.print(
|
||||||
|
|
@ -779,9 +793,6 @@ pub const Application = extern struct {
|
||||||
, .{ .font_family = font_family });
|
, .{ .font_family = font_family });
|
||||||
}
|
}
|
||||||
|
|
||||||
try loadRuntimeCss414(config, &writer);
|
|
||||||
try loadRuntimeCss416(config, &writer);
|
|
||||||
|
|
||||||
// ensure that we have a sentinel
|
// ensure that we have a sentinel
|
||||||
try writer.writeByte(0);
|
try writer.writeByte(0);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,25 @@ pub const SplitTree = extern struct {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pub const @"is-split" = struct {
|
||||||
|
pub const name = "is-split";
|
||||||
|
const impl = gobject.ext.defineProperty(
|
||||||
|
name,
|
||||||
|
Self,
|
||||||
|
bool,
|
||||||
|
.{
|
||||||
|
.default = false,
|
||||||
|
.accessor = gobject.ext.typedAccessor(
|
||||||
|
Self,
|
||||||
|
bool,
|
||||||
|
.{
|
||||||
|
.getter = getIsSplit,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const signals = struct {
|
pub const signals = struct {
|
||||||
|
|
@ -210,6 +229,14 @@ pub const SplitTree = extern struct {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bind is-split property for new surface
|
||||||
|
_ = self.as(gobject.Object).bindProperty(
|
||||||
|
"is-split",
|
||||||
|
surface.as(gobject.Object),
|
||||||
|
"is-split",
|
||||||
|
.{ .sync_create = true },
|
||||||
|
);
|
||||||
|
|
||||||
// Create our tree
|
// Create our tree
|
||||||
var single_tree = try Surface.Tree.init(alloc, surface);
|
var single_tree = try Surface.Tree.init(alloc, surface);
|
||||||
defer single_tree.deinit();
|
defer single_tree.deinit();
|
||||||
|
|
@ -511,6 +538,18 @@ pub const SplitTree = extern struct {
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn getIsSplit(self: *Self) bool {
|
||||||
|
const tree: *const Surface.Tree = self.private().tree orelse &.empty;
|
||||||
|
if (tree.isEmpty()) return false;
|
||||||
|
|
||||||
|
const root_handle: Surface.Tree.Node.Handle = .root;
|
||||||
|
const root = tree.nodes[root_handle.idx()];
|
||||||
|
return switch (root) {
|
||||||
|
.leaf => false,
|
||||||
|
.split => true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
//---------------------------------------------------------------
|
//---------------------------------------------------------------
|
||||||
// Virtual methods
|
// Virtual methods
|
||||||
|
|
||||||
|
|
@ -816,6 +855,9 @@ pub const SplitTree = extern struct {
|
||||||
v.grabFocus();
|
v.grabFocus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Our split status may have changed
|
||||||
|
self.as(gobject.Object).notifyByPspec(properties.@"is-split".impl.param_spec);
|
||||||
|
|
||||||
// Our active surface may have changed
|
// Our active surface may have changed
|
||||||
self.as(gobject.Object).notifyByPspec(properties.@"active-surface".impl.param_spec);
|
self.as(gobject.Object).notifyByPspec(properties.@"active-surface".impl.param_spec);
|
||||||
|
|
||||||
|
|
@ -873,6 +915,7 @@ pub const SplitTree = extern struct {
|
||||||
properties.@"has-surfaces".impl,
|
properties.@"has-surfaces".impl,
|
||||||
properties.@"is-zoomed".impl,
|
properties.@"is-zoomed".impl,
|
||||||
properties.tree.impl,
|
properties.tree.impl,
|
||||||
|
properties.@"is-split".impl,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Bindings
|
// Bindings
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ const gobject = @import("gobject");
|
||||||
const gtk = @import("gtk");
|
const gtk = @import("gtk");
|
||||||
|
|
||||||
const apprt = @import("../../../apprt.zig");
|
const apprt = @import("../../../apprt.zig");
|
||||||
|
const build_config = @import("../../../build_config.zig");
|
||||||
const datastruct = @import("../../../datastruct/main.zig");
|
const datastruct = @import("../../../datastruct/main.zig");
|
||||||
const font = @import("../../../font/main.zig");
|
const font = @import("../../../font/main.zig");
|
||||||
const input = @import("../../../input.zig");
|
const input = @import("../../../input.zig");
|
||||||
|
|
@ -50,6 +51,13 @@ pub const Surface = extern struct {
|
||||||
pub const Tree = datastruct.SplitTree(Self);
|
pub const Tree = datastruct.SplitTree(Self);
|
||||||
|
|
||||||
pub const properties = struct {
|
pub const properties = struct {
|
||||||
|
/// This property is set to true when the bell is ringing. Note that
|
||||||
|
/// this property will only emit a changed signal when there is a
|
||||||
|
/// full state change. If a bell is ringing and another bell event
|
||||||
|
/// comes through, the change notification will NOT be emitted.
|
||||||
|
///
|
||||||
|
/// If you need to know every scenario the bell is triggered,
|
||||||
|
/// listen to the `bell` signal instead.
|
||||||
pub const @"bell-ringing" = struct {
|
pub const @"bell-ringing" = struct {
|
||||||
pub const name = "bell-ringing";
|
pub const name = "bell-ringing";
|
||||||
const impl = gobject.ext.defineProperty(
|
const impl = gobject.ext.defineProperty(
|
||||||
|
|
@ -274,9 +282,40 @@ pub const Surface = extern struct {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pub const @"is-split" = struct {
|
||||||
|
pub const name = "is-split";
|
||||||
|
const impl = gobject.ext.defineProperty(
|
||||||
|
name,
|
||||||
|
Self,
|
||||||
|
bool,
|
||||||
|
.{
|
||||||
|
.default = false,
|
||||||
|
.accessor = gobject.ext.privateFieldAccessor(
|
||||||
|
Self,
|
||||||
|
Private,
|
||||||
|
&Private.offset,
|
||||||
|
"is_split",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const signals = struct {
|
pub const signals = struct {
|
||||||
|
/// Emitted whenever the bell event is received. Unlike the
|
||||||
|
/// `bell-ringing` property, this is emitted every time the event
|
||||||
|
/// is received and not just on state changes.
|
||||||
|
pub const bell = struct {
|
||||||
|
pub const name = "bell";
|
||||||
|
pub const connect = impl.connect;
|
||||||
|
const impl = gobject.ext.defineSignal(
|
||||||
|
name,
|
||||||
|
Self,
|
||||||
|
&.{},
|
||||||
|
void,
|
||||||
|
);
|
||||||
|
};
|
||||||
/// Emitted whenever the surface would like to be closed for any
|
/// Emitted whenever the surface would like to be closed for any
|
||||||
/// reason.
|
/// reason.
|
||||||
///
|
///
|
||||||
|
|
@ -502,6 +541,10 @@ pub const Surface = extern struct {
|
||||||
/// A weak reference to an inspector window.
|
/// A weak reference to an inspector window.
|
||||||
inspector: ?*InspectorWindow = null,
|
inspector: ?*InspectorWindow = null,
|
||||||
|
|
||||||
|
// True if the current surface is a split, this is used to apply
|
||||||
|
// unfocused-split-* options
|
||||||
|
is_split: bool = false,
|
||||||
|
|
||||||
// Template binds
|
// Template binds
|
||||||
child_exited_overlay: *ChildExited,
|
child_exited_overlay: *ChildExited,
|
||||||
context_menu: *gtk.PopoverMenu,
|
context_menu: *gtk.PopoverMenu,
|
||||||
|
|
@ -600,6 +643,16 @@ pub const Surface = extern struct {
|
||||||
return @intFromBool(config.@"bell-features".border);
|
return @intFromBool(config.@"bell-features".border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Callback used to determine whether unfocused-split-fill / unfocused-split-opacity
|
||||||
|
/// should be applied to the surface
|
||||||
|
fn closureShouldUnfocusedSplitBeShown(
|
||||||
|
_: *Self,
|
||||||
|
focused: c_int,
|
||||||
|
is_split: c_int,
|
||||||
|
) callconv(.c) c_int {
|
||||||
|
return @intFromBool(focused == 0 and is_split != 0);
|
||||||
|
}
|
||||||
|
|
||||||
pub fn toggleFullscreen(self: *Self) void {
|
pub fn toggleFullscreen(self: *Self) void {
|
||||||
signals.@"toggle-fullscreen".impl.emit(
|
signals.@"toggle-fullscreen".impl.emit(
|
||||||
self,
|
self,
|
||||||
|
|
@ -1227,19 +1280,11 @@ pub const Surface = extern struct {
|
||||||
|
|
||||||
// Unset environment varies set by snaps if we're running in a snap.
|
// Unset environment varies set by snaps if we're running in a snap.
|
||||||
// This allows Ghostty to further launch additional snaps.
|
// This allows Ghostty to further launch additional snaps.
|
||||||
if (env.get("SNAP")) |_| {
|
if (comptime build_config.snap) {
|
||||||
env.remove("SNAP");
|
if (env.get("SNAP") != null) try filterSnapPaths(
|
||||||
env.remove("DRIRC_CONFIGDIR");
|
alloc,
|
||||||
env.remove("__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS");
|
&env,
|
||||||
env.remove("__EGL_VENDOR_LIBRARY_DIRS");
|
);
|
||||||
env.remove("LD_LIBRARY_PATH");
|
|
||||||
env.remove("LIBGL_DRIVERS_PATH");
|
|
||||||
env.remove("LIBVA_DRIVERS_PATH");
|
|
||||||
env.remove("VK_LAYER_PATH");
|
|
||||||
env.remove("XLOCALEDIR");
|
|
||||||
env.remove("GDK_PIXBUF_MODULEDIR");
|
|
||||||
env.remove("GDK_PIXBUF_MODULE_FILE");
|
|
||||||
env.remove("GTK_PATH");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// This is a hack because it ties ourselves (optionally) to the
|
// This is a hack because it ties ourselves (optionally) to the
|
||||||
|
|
@ -1253,6 +1298,79 @@ pub const Surface = extern struct {
|
||||||
return env;
|
return env;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Filter out environment variables that start with forbidden prefixes.
|
||||||
|
fn filterSnapPaths(gpa: std.mem.Allocator, env_map: *std.process.EnvMap) !void {
|
||||||
|
comptime assert(build_config.snap);
|
||||||
|
|
||||||
|
const snap_vars = [_][]const u8{
|
||||||
|
"SNAP",
|
||||||
|
"SNAP_USER_COMMON",
|
||||||
|
"SNAP_USER_DATA",
|
||||||
|
"SNAP_DATA",
|
||||||
|
"SNAP_COMMON",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use an arena because everything in this function is temporary.
|
||||||
|
var arena = std.heap.ArenaAllocator.init(gpa);
|
||||||
|
defer arena.deinit();
|
||||||
|
const alloc = arena.allocator();
|
||||||
|
|
||||||
|
var env_to_remove = std.ArrayList([]const u8).init(alloc);
|
||||||
|
var env_to_update = std.ArrayList(struct {
|
||||||
|
key: []const u8,
|
||||||
|
value: []const u8,
|
||||||
|
}).init(alloc);
|
||||||
|
|
||||||
|
var it = env_map.iterator();
|
||||||
|
while (it.next()) |entry| {
|
||||||
|
const key = entry.key_ptr.*;
|
||||||
|
const value = entry.value_ptr.*;
|
||||||
|
|
||||||
|
// Ignore fields we set ourself
|
||||||
|
if (std.mem.eql(u8, key, "TERMINFO")) continue;
|
||||||
|
if (std.mem.startsWith(u8, key, "GHOSTTY")) continue;
|
||||||
|
|
||||||
|
// Any env var starting with SNAP must be removed
|
||||||
|
if (std.mem.startsWith(u8, key, "SNAP_")) {
|
||||||
|
try env_to_remove.append(key);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var filtered_paths = std.ArrayList([]const u8).init(alloc);
|
||||||
|
defer filtered_paths.deinit();
|
||||||
|
|
||||||
|
var modified = false;
|
||||||
|
var paths = std.mem.splitAny(u8, value, ":");
|
||||||
|
while (paths.next()) |path| {
|
||||||
|
var include = true;
|
||||||
|
for (snap_vars) |k| if (env_map.get(k)) |snap_path| {
|
||||||
|
if (snap_path.len == 0) continue;
|
||||||
|
if (std.mem.startsWith(u8, path, snap_path)) {
|
||||||
|
include = false;
|
||||||
|
modified = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (include) try filtered_paths.append(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modified) {
|
||||||
|
if (filtered_paths.items.len > 0) {
|
||||||
|
const new_value = try std.mem.join(alloc, ":", filtered_paths.items);
|
||||||
|
try env_to_update.append(.{ .key = key, .value = new_value });
|
||||||
|
} else {
|
||||||
|
try env_to_remove.append(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (env_to_update.items) |item| try env_map.put(
|
||||||
|
item.key,
|
||||||
|
item.value,
|
||||||
|
);
|
||||||
|
for (env_to_remove.items) |key| _ = env_map.remove(key);
|
||||||
|
}
|
||||||
|
|
||||||
pub fn clipboardRequest(
|
pub fn clipboardRequest(
|
||||||
self: *Self,
|
self: *Self,
|
||||||
clipboard_type: apprt.Clipboard,
|
clipboard_type: apprt.Clipboard,
|
||||||
|
|
@ -1576,6 +1694,16 @@ pub const Surface = extern struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn setBellRinging(self: *Self, ringing: bool) void {
|
pub fn setBellRinging(self: *Self, ringing: bool) void {
|
||||||
|
// Prevent duplicate change notifications if the signals we emit
|
||||||
|
// in this function cause this state to change again.
|
||||||
|
self.as(gobject.Object).freezeNotify();
|
||||||
|
defer self.as(gobject.Object).thawNotify();
|
||||||
|
|
||||||
|
// Logic around bell reaction happens on every event even if we're
|
||||||
|
// already in the ringing state.
|
||||||
|
if (ringing) self.ringBell();
|
||||||
|
|
||||||
|
// Property change only happens on actual state change
|
||||||
const priv = self.private();
|
const priv = self.private();
|
||||||
if (priv.bell_ringing == ringing) return;
|
if (priv.bell_ringing == ringing) return;
|
||||||
priv.bell_ringing = ringing;
|
priv.bell_ringing = ringing;
|
||||||
|
|
@ -1760,20 +1888,26 @@ pub const Surface = extern struct {
|
||||||
self.as(gtk.Widget).setCursorFromName(name.ptr);
|
self.as(gtk.Widget).setCursorFromName(name.ptr);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn propBellRinging(
|
/// Handle bell features that need to happen every time a BEL is received
|
||||||
self: *Self,
|
/// Currently this is audio and system but this could change in the future.
|
||||||
_: *gobject.ParamSpec,
|
fn ringBell(self: *Self) void {
|
||||||
_: ?*anyopaque,
|
|
||||||
) callconv(.c) void {
|
|
||||||
const priv = self.private();
|
const priv = self.private();
|
||||||
if (!priv.bell_ringing) return;
|
|
||||||
|
// Emit the signal
|
||||||
|
signals.bell.impl.emit(
|
||||||
|
self,
|
||||||
|
null,
|
||||||
|
.{},
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
// Activate actions if they exist
|
// Activate actions if they exist
|
||||||
_ = self.as(gtk.Widget).activateAction("tab.ring-bell", null);
|
_ = self.as(gtk.Widget).activateAction("tab.ring-bell", null);
|
||||||
_ = self.as(gtk.Widget).activateAction("win.ring-bell", null);
|
_ = self.as(gtk.Widget).activateAction("win.ring-bell", null);
|
||||||
|
|
||||||
// Do our sound
|
|
||||||
const config = if (priv.config) |c| c.get() else return;
|
const config = if (priv.config) |c| c.get() else return;
|
||||||
|
|
||||||
|
// Do our sound
|
||||||
if (config.@"bell-features".audio) audio: {
|
if (config.@"bell-features".audio) audio: {
|
||||||
const config_path = config.@"bell-audio-path" orelse break :audio;
|
const config_path = config.@"bell-audio-path" orelse break :audio;
|
||||||
const path, const required = switch (config_path) {
|
const path, const required = switch (config_path) {
|
||||||
|
|
@ -2761,8 +2895,8 @@ pub const Surface = extern struct {
|
||||||
class.bindTemplateCallback("notify_mouse_hover_url", &propMouseHoverUrl);
|
class.bindTemplateCallback("notify_mouse_hover_url", &propMouseHoverUrl);
|
||||||
class.bindTemplateCallback("notify_mouse_hidden", &propMouseHidden);
|
class.bindTemplateCallback("notify_mouse_hidden", &propMouseHidden);
|
||||||
class.bindTemplateCallback("notify_mouse_shape", &propMouseShape);
|
class.bindTemplateCallback("notify_mouse_shape", &propMouseShape);
|
||||||
class.bindTemplateCallback("notify_bell_ringing", &propBellRinging);
|
|
||||||
class.bindTemplateCallback("should_border_be_shown", &closureShouldBorderBeShown);
|
class.bindTemplateCallback("should_border_be_shown", &closureShouldBorderBeShown);
|
||||||
|
class.bindTemplateCallback("should_unfocused_split_be_shown", &closureShouldUnfocusedSplitBeShown);
|
||||||
|
|
||||||
// Properties
|
// Properties
|
||||||
gobject.ext.registerProperties(class, &.{
|
gobject.ext.registerProperties(class, &.{
|
||||||
|
|
@ -2781,9 +2915,11 @@ pub const Surface = extern struct {
|
||||||
properties.title.impl,
|
properties.title.impl,
|
||||||
properties.@"title-override".impl,
|
properties.@"title-override".impl,
|
||||||
properties.zoom.impl,
|
properties.zoom.impl,
|
||||||
|
properties.@"is-split".impl,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Signals
|
// Signals
|
||||||
|
signals.bell.impl.register(.{});
|
||||||
signals.@"close-request".impl.register(.{});
|
signals.@"close-request".impl.register(.{});
|
||||||
signals.@"clipboard-read".impl.register(.{});
|
signals.@"clipboard-read".impl.register(.{});
|
||||||
signals.@"clipboard-write".impl.register(.{});
|
signals.@"clipboard-write".impl.register(.{});
|
||||||
|
|
|
||||||
|
|
@ -389,8 +389,14 @@ pub const Tab = extern struct {
|
||||||
// the terminal title if it exists, otherwise a default string.
|
// the terminal title if it exists, otherwise a default string.
|
||||||
const plain = plain: {
|
const plain = plain: {
|
||||||
const default = "Ghostty";
|
const default = "Ghostty";
|
||||||
|
const config_title: ?[*:0]const u8 = title: {
|
||||||
|
const config = config_ orelse break :title null;
|
||||||
|
break :title config.get().title orelse null;
|
||||||
|
};
|
||||||
|
|
||||||
const plain = override_ orelse
|
const plain = override_ orelse
|
||||||
terminal_ orelse
|
terminal_ orelse
|
||||||
|
config_title orelse
|
||||||
break :plain default;
|
break :plain default;
|
||||||
break :plain std.mem.span(plain);
|
break :plain std.mem.span(plain);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -697,6 +697,19 @@ pub const Window = extern struct {
|
||||||
var it = tree.iterator();
|
var it = tree.iterator();
|
||||||
while (it.next()) |entry| {
|
while (it.next()) |entry| {
|
||||||
const surface = entry.view;
|
const surface = entry.view;
|
||||||
|
// Before adding any new signal handlers, disconnect any that we may
|
||||||
|
// have added before. Otherwise we may get multiple handlers for the
|
||||||
|
// same signal.
|
||||||
|
_ = gobject.signalHandlersDisconnectMatched(
|
||||||
|
surface.as(gobject.Object),
|
||||||
|
.{ .data = true },
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
self,
|
||||||
|
);
|
||||||
|
|
||||||
_ = Surface.signals.@"present-request".connect(
|
_ = Surface.signals.@"present-request".connect(
|
||||||
surface,
|
surface,
|
||||||
*Self,
|
*Self,
|
||||||
|
|
@ -1002,6 +1015,15 @@ pub const Window = extern struct {
|
||||||
_: *gobject.ParamSpec,
|
_: *gobject.ParamSpec,
|
||||||
self: *Self,
|
self: *Self,
|
||||||
) callconv(.c) void {
|
) callconv(.c) void {
|
||||||
|
// Hide quick-terminal if set to autohide
|
||||||
|
if (self.isQuickTerminal()) {
|
||||||
|
if (self.getConfig()) |cfg| {
|
||||||
|
if (cfg.get().@"quick-terminal-autohide" and self.as(gtk.Window).isActive() == 0) {
|
||||||
|
self.toggleVisibility();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Don't change urgency if we're not the active window.
|
// Don't change urgency if we're not the active window.
|
||||||
if (self.as(gtk.Window).isActive() == 0) return;
|
if (self.as(gtk.Window).isActive() == 0) return;
|
||||||
|
|
||||||
|
|
@ -1489,6 +1511,13 @@ pub const Window = extern struct {
|
||||||
const priv = self.private();
|
const priv = self.private();
|
||||||
if (priv.tab_view.getNPages() == 0) {
|
if (priv.tab_view.getNPages() == 0) {
|
||||||
// If we have no pages left then we want to close window.
|
// If we have no pages left then we want to close window.
|
||||||
|
|
||||||
|
// If the tab overview is open, then we don't close the window
|
||||||
|
// because its a rather abrupt experience. This also fixes an
|
||||||
|
// issue where dragging out the last tab in the tab overview
|
||||||
|
// won't cause Ghostty to exit.
|
||||||
|
if (priv.tab_overview.getOpen() != 0) return;
|
||||||
|
|
||||||
self.as(gtk.Window).close();
|
self.as(gtk.Window).close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -115,6 +115,20 @@ Overlay terminal_page {
|
||||||
label: bind template.mouse-hover-url;
|
label: bind template.mouse-hover-url;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[overlay]
|
||||||
|
// Apply unfocused-split-fill and unfocused-split-opacity to current surface
|
||||||
|
// this is only applied when a tab has more than one surface
|
||||||
|
Revealer {
|
||||||
|
reveal-child: bind $should_unfocused_split_be_shown(template.focused, template.is-split) as <bool>;
|
||||||
|
transition-duration: 0;
|
||||||
|
|
||||||
|
DrawingArea {
|
||||||
|
styles [
|
||||||
|
"unfocused-split",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Event controllers for interactivity
|
// Event controllers for interactivity
|
||||||
EventControllerFocus {
|
EventControllerFocus {
|
||||||
enter => $focus_enter();
|
enter => $focus_enter();
|
||||||
|
|
@ -155,7 +169,6 @@ template $GhosttySurface: Adw.Bin {
|
||||||
"surface",
|
"surface",
|
||||||
]
|
]
|
||||||
|
|
||||||
notify::bell-ringing => $notify_bell_ringing();
|
|
||||||
notify::config => $notify_config();
|
notify::config => $notify_config();
|
||||||
notify::error => $notify_error();
|
notify::error => $notify_error();
|
||||||
notify::mouse-hover-url => $notify_mouse_hover_url();
|
notify::mouse-hover-url => $notify_mouse_hover_url();
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,14 @@ template $GhosttySurfaceTitleDialog: Adw.AlertDialog {
|
||||||
body: _("Leave blank to restore the default title.");
|
body: _("Leave blank to restore the default title.");
|
||||||
|
|
||||||
responses [
|
responses [
|
||||||
cancel: _("Cancel") suggested,
|
cancel: _("Cancel"),
|
||||||
ok: _("OK") destructive,
|
ok: _("OK") suggested,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
default-response: "ok";
|
||||||
focus-widget: entry;
|
focus-widget: entry;
|
||||||
|
|
||||||
extra-child: Entry entry {};
|
extra-child: Entry entry {
|
||||||
|
activates-default: true;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ const GitVersion = @import("GitVersion.zig");
|
||||||
/// TODO: When Zig 0.14 is released, derive this from build.zig.zon directly.
|
/// TODO: When Zig 0.14 is released, derive this from build.zig.zon directly.
|
||||||
/// Until then this MUST match build.zig.zon and should always be the
|
/// Until then this MUST match build.zig.zon and should always be the
|
||||||
/// _next_ version to release.
|
/// _next_ version to release.
|
||||||
const app_version: std.SemanticVersion = .{ .major = 1, .minor = 2, .patch = 0 };
|
const app_version: std.SemanticVersion = .{ .major = 1, .minor = 2, .patch = 3 };
|
||||||
|
|
||||||
/// Standard build configuration options.
|
/// Standard build configuration options.
|
||||||
optimize: std.builtin.OptimizeMode,
|
optimize: std.builtin.OptimizeMode,
|
||||||
|
|
@ -51,6 +51,7 @@ patch_rpath: ?[]const u8 = null,
|
||||||
|
|
||||||
/// Artifacts
|
/// Artifacts
|
||||||
flatpak: bool = false,
|
flatpak: bool = false,
|
||||||
|
snap: bool = false,
|
||||||
emit_bench: bool = false,
|
emit_bench: bool = false,
|
||||||
emit_docs: bool = false,
|
emit_docs: bool = false,
|
||||||
emit_exe: bool = false,
|
emit_exe: bool = false,
|
||||||
|
|
@ -59,6 +60,7 @@ emit_macos_app: bool = false,
|
||||||
emit_terminfo: bool = false,
|
emit_terminfo: bool = false,
|
||||||
emit_termcap: bool = false,
|
emit_termcap: bool = false,
|
||||||
emit_test_exe: bool = false,
|
emit_test_exe: bool = false,
|
||||||
|
emit_themes: bool = false,
|
||||||
emit_xcframework: bool = false,
|
emit_xcframework: bool = false,
|
||||||
emit_webdata: bool = false,
|
emit_webdata: bool = false,
|
||||||
emit_unicode_table_gen: bool = false,
|
emit_unicode_table_gen: bool = false,
|
||||||
|
|
@ -152,6 +154,12 @@ pub fn init(b: *std.Build) !Config {
|
||||||
"Build for Flatpak (integrates with Flatpak APIs). Only has an effect targeting Linux.",
|
"Build for Flatpak (integrates with Flatpak APIs). Only has an effect targeting Linux.",
|
||||||
) orelse false;
|
) orelse false;
|
||||||
|
|
||||||
|
config.snap = b.option(
|
||||||
|
bool,
|
||||||
|
"snap",
|
||||||
|
"Build for Snap (do specific Snap operations). Only has an effect targeting Linux.",
|
||||||
|
) orelse false;
|
||||||
|
|
||||||
config.sentry = b.option(
|
config.sentry = b.option(
|
||||||
bool,
|
bool,
|
||||||
"sentry",
|
"sentry",
|
||||||
|
|
@ -359,6 +367,12 @@ pub fn init(b: *std.Build) !Config {
|
||||||
.ReleaseSafe, .ReleaseFast, .ReleaseSmall => false,
|
.ReleaseSafe, .ReleaseFast, .ReleaseSmall => false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
config.emit_themes = b.option(
|
||||||
|
bool,
|
||||||
|
"emit-themes",
|
||||||
|
"Install bundled iTerm2-Color-Schemes Ghostty themes",
|
||||||
|
) orelse true;
|
||||||
|
|
||||||
config.emit_webdata = b.option(
|
config.emit_webdata = b.option(
|
||||||
bool,
|
bool,
|
||||||
"emit-webdata",
|
"emit-webdata",
|
||||||
|
|
@ -442,6 +456,7 @@ pub fn addOptions(self: *const Config, step: *std.Build.Step.Options) !void {
|
||||||
// We need to break these down individual because addOption doesn't
|
// We need to break these down individual because addOption doesn't
|
||||||
// support all types.
|
// support all types.
|
||||||
step.addOption(bool, "flatpak", self.flatpak);
|
step.addOption(bool, "flatpak", self.flatpak);
|
||||||
|
step.addOption(bool, "snap", self.snap);
|
||||||
step.addOption(bool, "x11", self.x11);
|
step.addOption(bool, "x11", self.x11);
|
||||||
step.addOption(bool, "wayland", self.wayland);
|
step.addOption(bool, "wayland", self.wayland);
|
||||||
step.addOption(bool, "sentry", self.sentry);
|
step.addOption(bool, "sentry", self.sentry);
|
||||||
|
|
@ -506,6 +521,7 @@ pub fn fromOptions() Config {
|
||||||
.app_runtime = std.meta.stringToEnum(apprt.Runtime, @tagName(options.app_runtime)).?,
|
.app_runtime = std.meta.stringToEnum(apprt.Runtime, @tagName(options.app_runtime)).?,
|
||||||
.font_backend = std.meta.stringToEnum(font.Backend, @tagName(options.font_backend)).?,
|
.font_backend = std.meta.stringToEnum(font.Backend, @tagName(options.font_backend)).?,
|
||||||
.renderer = std.meta.stringToEnum(rendererpkg.Impl, @tagName(options.renderer)).?,
|
.renderer = std.meta.stringToEnum(rendererpkg.Impl, @tagName(options.renderer)).?,
|
||||||
|
.snap = options.snap,
|
||||||
.exe_entrypoint = std.meta.stringToEnum(ExeEntrypoint, @tagName(options.exe_entrypoint)).?,
|
.exe_entrypoint = std.meta.stringToEnum(ExeEntrypoint, @tagName(options.exe_entrypoint)).?,
|
||||||
.wasm_target = std.meta.stringToEnum(WasmTarget, @tagName(options.wasm_target)).?,
|
.wasm_target = std.meta.stringToEnum(WasmTarget, @tagName(options.wasm_target)).?,
|
||||||
.wasm_shared = options.wasm_shared,
|
.wasm_shared = options.wasm_shared,
|
||||||
|
|
|
||||||
|
|
@ -118,14 +118,16 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyResources {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Themes
|
// Themes
|
||||||
if (b.lazyDependency("iterm2_themes", .{})) |upstream| {
|
if (cfg.emit_themes) {
|
||||||
const install_step = b.addInstallDirectory(.{
|
if (b.lazyDependency("iterm2_themes", .{})) |upstream| {
|
||||||
.source_dir = upstream.path(""),
|
const install_step = b.addInstallDirectory(.{
|
||||||
.install_dir = .{ .custom = "share" },
|
.source_dir = upstream.path(""),
|
||||||
.install_subdir = b.pathJoin(&.{ "ghostty", "themes" }),
|
.install_dir = .{ .custom = "share" },
|
||||||
.exclude_extensions = &.{".md"},
|
.install_subdir = b.pathJoin(&.{ "ghostty", "themes" }),
|
||||||
});
|
.exclude_extensions = &.{".md"},
|
||||||
try steps.append(&install_step.step);
|
});
|
||||||
|
try steps.append(&install_step.step);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fish shell completions
|
// Fish shell completions
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ pub const artifact = Artifact.detect();
|
||||||
const config = BuildConfig.fromOptions();
|
const config = BuildConfig.fromOptions();
|
||||||
pub const exe_entrypoint = config.exe_entrypoint;
|
pub const exe_entrypoint = config.exe_entrypoint;
|
||||||
pub const flatpak = options.flatpak;
|
pub const flatpak = options.flatpak;
|
||||||
|
pub const snap = options.snap;
|
||||||
pub const app_runtime: apprt.Runtime = config.app_runtime;
|
pub const app_runtime: apprt.Runtime = config.app_runtime;
|
||||||
pub const font_backend: font.Backend = config.font_backend;
|
pub const font_backend: font.Backend = config.font_backend;
|
||||||
pub const renderer: rendererpkg.Impl = config.renderer;
|
pub const renderer: rendererpkg.Impl = config.renderer;
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ const diags = @import("diagnostics.zig");
|
||||||
const internal_os = @import("../os/main.zig");
|
const internal_os = @import("../os/main.zig");
|
||||||
const Diagnostic = diags.Diagnostic;
|
const Diagnostic = diags.Diagnostic;
|
||||||
const DiagnosticList = diags.DiagnosticList;
|
const DiagnosticList = diags.DiagnosticList;
|
||||||
|
const CommaSplitter = @import("CommaSplitter.zig");
|
||||||
|
|
||||||
const log = std.log.scoped(.cli);
|
const log = std.log.scoped(.cli);
|
||||||
|
|
||||||
|
|
@ -506,13 +507,18 @@ pub fn parseTaggedUnion(comptime T: type, alloc: Allocator, v: []const u8) !T {
|
||||||
|
|
||||||
fn parseStruct(comptime T: type, alloc: Allocator, v: []const u8) !T {
|
fn parseStruct(comptime T: type, alloc: Allocator, v: []const u8) !T {
|
||||||
return switch (@typeInfo(T).@"struct".layout) {
|
return switch (@typeInfo(T).@"struct".layout) {
|
||||||
.auto => parseAutoStruct(T, alloc, v),
|
.auto => parseAutoStruct(T, alloc, v, null),
|
||||||
.@"packed" => parsePackedStruct(T, v),
|
.@"packed" => parsePackedStruct(T, v),
|
||||||
else => @compileError("unsupported struct layout"),
|
else => @compileError("unsupported struct layout"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn parseAutoStruct(comptime T: type, alloc: Allocator, v: []const u8) !T {
|
pub fn parseAutoStruct(
|
||||||
|
comptime T: type,
|
||||||
|
alloc: Allocator,
|
||||||
|
v: []const u8,
|
||||||
|
default_: ?T,
|
||||||
|
) !T {
|
||||||
const info = @typeInfo(T).@"struct";
|
const info = @typeInfo(T).@"struct";
|
||||||
comptime assert(info.layout == .auto);
|
comptime assert(info.layout == .auto);
|
||||||
|
|
||||||
|
|
@ -527,24 +533,31 @@ pub fn parseAutoStruct(comptime T: type, alloc: Allocator, v: []const u8) !T {
|
||||||
const FieldSet = std.StaticBitSet(info.fields.len);
|
const FieldSet = std.StaticBitSet(info.fields.len);
|
||||||
var fields_set: FieldSet = .initEmpty();
|
var fields_set: FieldSet = .initEmpty();
|
||||||
|
|
||||||
// We split each value by ","
|
// We split each value by "," allowing for quoting and escaping.
|
||||||
var iter = std.mem.splitSequence(u8, v, ",");
|
var iter: CommaSplitter = .init(v);
|
||||||
loop: while (iter.next()) |entry| {
|
loop: while (try iter.next()) |entry| {
|
||||||
// Find the key/value, trimming whitespace. The value may be quoted
|
// Find the key/value, trimming whitespace. The value may be quoted
|
||||||
// which we strip the quotes from.
|
// which we strip the quotes from.
|
||||||
const idx = mem.indexOf(u8, entry, ":") orelse return error.InvalidValue;
|
const idx = mem.indexOf(u8, entry, ":") orelse return error.InvalidValue;
|
||||||
const key = std.mem.trim(u8, entry[0..idx], whitespace);
|
const key = std.mem.trim(u8, entry[0..idx], whitespace);
|
||||||
|
|
||||||
|
// used if we need to decode a double-quoted string.
|
||||||
|
var buf: std.ArrayListUnmanaged(u8) = .empty;
|
||||||
|
defer buf.deinit(alloc);
|
||||||
|
|
||||||
const value = value: {
|
const value = value: {
|
||||||
var value = std.mem.trim(u8, entry[idx + 1 ..], whitespace);
|
const value = std.mem.trim(u8, entry[idx + 1 ..], whitespace);
|
||||||
|
|
||||||
// Detect a quoted string.
|
// Detect a quoted string.
|
||||||
if (value.len >= 2 and
|
if (value.len >= 2 and
|
||||||
value[0] == '"' and
|
value[0] == '"' and
|
||||||
value[value.len - 1] == '"')
|
value[value.len - 1] == '"')
|
||||||
{
|
{
|
||||||
// Trim quotes since our CLI args processor expects
|
// Decode a double-quoted string as a Zig string literal.
|
||||||
// quotes to already be gone.
|
const writer = buf.writer(alloc);
|
||||||
value = value[1 .. value.len - 1];
|
const parsed = try std.zig.string_literal.parseWrite(writer, value);
|
||||||
|
if (parsed == .failure) return error.InvalidValue;
|
||||||
|
break :value buf.items;
|
||||||
}
|
}
|
||||||
|
|
||||||
break :value value;
|
break :value value;
|
||||||
|
|
@ -565,9 +578,18 @@ pub fn parseAutoStruct(comptime T: type, alloc: Allocator, v: []const u8) !T {
|
||||||
// Ensure all required fields are set
|
// Ensure all required fields are set
|
||||||
inline for (info.fields, 0..) |field, i| {
|
inline for (info.fields, 0..) |field, i| {
|
||||||
if (!fields_set.isSet(i)) {
|
if (!fields_set.isSet(i)) {
|
||||||
const default_ptr = field.default_value_ptr orelse return error.InvalidValue;
|
@field(result, field.name) = default: {
|
||||||
const typed_ptr: *const field.type = @alignCast(@ptrCast(default_ptr));
|
// If we're given a default value then we inherit those.
|
||||||
@field(result, field.name) = typed_ptr.*;
|
// 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(84, data.value.b);
|
||||||
try testing.expectEqual(24, data.value.c);
|
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(
|
try testing.expectError(
|
||||||
error.InvalidValue,
|
error.InvalidValue,
|
||||||
parseIntoField(@TypeOf(data), alloc, &data, "value", "a:hello"),
|
parseIntoField(@TypeOf(data), alloc, &data, "value", "a:hello"),
|
||||||
|
|
|
||||||
|
|
@ -133,13 +133,23 @@ pub fn run(alloc: Allocator) !u8 {
|
||||||
// so this is not a big deal.
|
// so this is not a big deal.
|
||||||
comptime assert(builtin.link_libc);
|
comptime assert(builtin.link_libc);
|
||||||
|
|
||||||
const editorZ = try alloc.dupeZ(u8, editor);
|
var buf: std.ArrayListUnmanaged(u8) = .empty;
|
||||||
defer alloc.free(editorZ);
|
errdefer buf.deinit(alloc);
|
||||||
const pathZ = try alloc.dupeZ(u8, path);
|
|
||||||
defer alloc.free(pathZ);
|
const writer = buf.writer(alloc);
|
||||||
|
var shellescape: internal_os.ShellEscapeWriter(std.ArrayListUnmanaged(u8).Writer) = .init(writer);
|
||||||
|
var shellescapewriter = shellescape.writer();
|
||||||
|
|
||||||
|
try writer.writeAll(editor);
|
||||||
|
try writer.writeByte(' ');
|
||||||
|
try shellescapewriter.writeAll(path);
|
||||||
|
|
||||||
|
const command = try buf.toOwnedSliceSentinel(alloc, 0);
|
||||||
|
defer alloc.free(command);
|
||||||
|
|
||||||
const err = std.posix.execvpeZ(
|
const err = std.posix.execvpeZ(
|
||||||
editorZ,
|
"sh",
|
||||||
&.{ editorZ, pathZ },
|
&.{ "sh", "-c", command },
|
||||||
std.c.environ,
|
std.c.environ,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -895,6 +895,42 @@ const Preview = struct {
|
||||||
config.background.b,
|
config.background.b,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
const cursor_fg: vaxis.Color = if (config.@"cursor-text") |cursor_text| .{
|
||||||
|
.rgb = [_]u8{
|
||||||
|
cursor_text.color.r,
|
||||||
|
cursor_text.color.g,
|
||||||
|
cursor_text.color.b,
|
||||||
|
},
|
||||||
|
} else bg;
|
||||||
|
const cursor_bg: vaxis.Color = if (config.@"cursor-color") |cursor_bg| .{
|
||||||
|
.rgb = [_]u8{
|
||||||
|
cursor_bg.color.r,
|
||||||
|
cursor_bg.color.g,
|
||||||
|
cursor_bg.color.b,
|
||||||
|
},
|
||||||
|
} else fg;
|
||||||
|
const selection_fg: vaxis.Color = if (config.@"selection-foreground") |selection_fg| .{
|
||||||
|
.rgb = [_]u8{
|
||||||
|
selection_fg.color.r,
|
||||||
|
selection_fg.color.g,
|
||||||
|
selection_fg.color.b,
|
||||||
|
},
|
||||||
|
} else bg;
|
||||||
|
const selection_bg: vaxis.Color = if (config.@"selection-background") |selection_bg| .{
|
||||||
|
.rgb = [_]u8{
|
||||||
|
selection_bg.color.r,
|
||||||
|
selection_bg.color.g,
|
||||||
|
selection_bg.color.b,
|
||||||
|
},
|
||||||
|
} else fg;
|
||||||
|
const cursor: vaxis.Style = .{
|
||||||
|
.fg = cursor_fg,
|
||||||
|
.bg = cursor_bg,
|
||||||
|
};
|
||||||
|
const standard_selection: vaxis.Style = .{
|
||||||
|
.fg = selection_fg,
|
||||||
|
.bg = selection_bg,
|
||||||
|
};
|
||||||
const standard: vaxis.Style = .{
|
const standard: vaxis.Style = .{
|
||||||
.fg = fg,
|
.fg = fg,
|
||||||
.bg = bg,
|
.bg = bg,
|
||||||
|
|
@ -1433,11 +1469,8 @@ const Preview = struct {
|
||||||
&.{
|
&.{
|
||||||
.{ .text = " 14 │ ", .style = color238 },
|
.{ .text = " 14 │ ", .style = color238 },
|
||||||
.{ .text = "try ", .style = color5 },
|
.{ .text = "try ", .style = color5 },
|
||||||
.{ .text = "stdout.print(", .style = standard },
|
.{ .text = "stdout.print(\"{d}\\n\", .{i})", .style = standard_selection },
|
||||||
.{ .text = "\"{d}", .style = color10 },
|
.{ .text = ";", .style = cursor },
|
||||||
.{ .text = "\\n", .style = color12 },
|
|
||||||
.{ .text = "\"", .style = color10 },
|
|
||||||
.{ .text = ", .{i});", .style = standard },
|
|
||||||
},
|
},
|
||||||
.{
|
.{
|
||||||
.row_offset = 17,
|
.row_offset = 17,
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,9 @@ const std = @import("std");
|
||||||
const builtin = @import("builtin");
|
const builtin = @import("builtin");
|
||||||
const assert = std.debug.assert;
|
const assert = std.debug.assert;
|
||||||
const Allocator = std.mem.Allocator;
|
const Allocator = std.mem.Allocator;
|
||||||
const xdg = @import("../../os/main.zig").xdg;
|
const internal_os = @import("../../os/main.zig");
|
||||||
const TempDir = @import("../../os/main.zig").TempDir;
|
const xdg = internal_os.xdg;
|
||||||
|
const TempDir = internal_os.TempDir;
|
||||||
const Entry = @import("Entry.zig");
|
const Entry = @import("Entry.zig");
|
||||||
|
|
||||||
// 512KB - sufficient for approximately 10k entries
|
// 512KB - sufficient for approximately 10k entries
|
||||||
|
|
@ -332,48 +333,28 @@ fn isValidCacheKey(key: []const u8) bool {
|
||||||
if (std.mem.indexOf(u8, key, "@")) |at_pos| {
|
if (std.mem.indexOf(u8, key, "@")) |at_pos| {
|
||||||
const user = key[0..at_pos];
|
const user = key[0..at_pos];
|
||||||
const hostname = key[at_pos + 1 ..];
|
const hostname = key[at_pos + 1 ..];
|
||||||
return isValidUser(user) and isValidHostname(hostname);
|
return isValidUser(user) and isValidHost(hostname);
|
||||||
}
|
}
|
||||||
|
|
||||||
return isValidHostname(key);
|
return isValidHost(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Basic hostname validation - accepts domains and IPs
|
// Checks if a host is a valid hostname or IP address
|
||||||
// (including IPv6 in brackets)
|
fn isValidHost(host: []const u8) bool {
|
||||||
fn isValidHostname(host: []const u8) bool {
|
// First check for valid hostnames because this is assumed to be the more
|
||||||
if (host.len == 0 or host.len > 253) return false;
|
// likely ssh host format.
|
||||||
|
if (internal_os.hostname.isValid(host)) {
|
||||||
// Handle IPv6 addresses in brackets
|
return true;
|
||||||
if (host.len >= 4 and host[0] == '[' and host[host.len - 1] == ']') {
|
|
||||||
const ipv6_part = host[1 .. host.len - 1];
|
|
||||||
if (ipv6_part.len == 0) return false;
|
|
||||||
var has_colon = false;
|
|
||||||
for (ipv6_part) |c| {
|
|
||||||
switch (c) {
|
|
||||||
'a'...'f', 'A'...'F', '0'...'9' => {},
|
|
||||||
':' => has_colon = true,
|
|
||||||
else => return false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return has_colon;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Standard hostname/domain validation
|
// We also accept valid IP addresses. In practice, IPv4 addresses are also
|
||||||
for (host) |c| {
|
// considered valid hostnames due to their overlapping syntax, so we can
|
||||||
switch (c) {
|
// simplify this check to be IPv6-specific.
|
||||||
'a'...'z', 'A'...'Z', '0'...'9', '.', '-' => {},
|
if (std.net.Address.parseIp6(host, 0)) |_| {
|
||||||
else => return false,
|
return true;
|
||||||
}
|
} else |_| {
|
||||||
}
|
|
||||||
|
|
||||||
// No leading/trailing dots or hyphens, no consecutive dots
|
|
||||||
if (host[0] == '.' or host[0] == '-' or
|
|
||||||
host[host.len - 1] == '.' or host[host.len - 1] == '-')
|
|
||||||
{
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return std.mem.indexOf(u8, host, "..") == null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn isValidUser(user: []const u8) bool {
|
fn isValidUser(user: []const u8) bool {
|
||||||
|
|
@ -467,42 +448,36 @@ test "disk cache operations" {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tests
|
// Tests
|
||||||
test "hostname validation - valid cases" {
|
|
||||||
const testing = std.testing;
|
|
||||||
try testing.expect(isValidHostname("example.com"));
|
|
||||||
try testing.expect(isValidHostname("sub.example.com"));
|
|
||||||
try testing.expect(isValidHostname("host-name.domain.org"));
|
|
||||||
try testing.expect(isValidHostname("192.168.1.1"));
|
|
||||||
try testing.expect(isValidHostname("a"));
|
|
||||||
try testing.expect(isValidHostname("1"));
|
|
||||||
}
|
|
||||||
|
|
||||||
test "hostname validation - IPv6 addresses" {
|
test isValidHost {
|
||||||
const testing = std.testing;
|
const testing = std.testing;
|
||||||
try testing.expect(isValidHostname("[::1]"));
|
|
||||||
try testing.expect(isValidHostname("[2001:db8::1]"));
|
|
||||||
try testing.expect(!isValidHostname("[fe80::1%eth0]")); // Interface notation not supported
|
|
||||||
try testing.expect(!isValidHostname("[]")); // Empty IPv6
|
|
||||||
try testing.expect(!isValidHostname("[invalid]")); // No colons
|
|
||||||
}
|
|
||||||
|
|
||||||
test "hostname validation - invalid cases" {
|
// Valid hostnames
|
||||||
const testing = std.testing;
|
try testing.expect(isValidHost("localhost"));
|
||||||
try testing.expect(!isValidHostname(""));
|
try testing.expect(isValidHost("example.com"));
|
||||||
try testing.expect(!isValidHostname("host\nname"));
|
try testing.expect(isValidHost("sub.example.com"));
|
||||||
try testing.expect(!isValidHostname(".example.com"));
|
|
||||||
try testing.expect(!isValidHostname("example.com."));
|
|
||||||
try testing.expect(!isValidHostname("host..domain"));
|
|
||||||
try testing.expect(!isValidHostname("-hostname"));
|
|
||||||
try testing.expect(!isValidHostname("hostname-"));
|
|
||||||
try testing.expect(!isValidHostname("host name"));
|
|
||||||
try testing.expect(!isValidHostname("host_name"));
|
|
||||||
try testing.expect(!isValidHostname("host@domain"));
|
|
||||||
try testing.expect(!isValidHostname("host:port"));
|
|
||||||
|
|
||||||
// Too long
|
// IPv4 addresses
|
||||||
const long_host = "a" ** 254;
|
try testing.expect(isValidHost("127.0.0.1"));
|
||||||
try testing.expect(!isValidHostname(long_host));
|
try testing.expect(isValidHost("192.168.1.1"));
|
||||||
|
|
||||||
|
// IPv6 addresses
|
||||||
|
try testing.expect(isValidHost("::1"));
|
||||||
|
try testing.expect(isValidHost("2001:db8::1"));
|
||||||
|
try testing.expect(isValidHost("2001:db8:0:1:1:1:1:1"));
|
||||||
|
try testing.expect(!isValidHost("fe80::1%eth0")); // scopes not supported
|
||||||
|
|
||||||
|
// Invalid hosts
|
||||||
|
try testing.expect(!isValidHost(""));
|
||||||
|
try testing.expect(!isValidHost("host\nname"));
|
||||||
|
try testing.expect(!isValidHost(".example.com"));
|
||||||
|
try testing.expect(!isValidHost("host..domain"));
|
||||||
|
try testing.expect(!isValidHost("-hostname"));
|
||||||
|
try testing.expect(!isValidHost("hostname-"));
|
||||||
|
try testing.expect(!isValidHost("host name"));
|
||||||
|
try testing.expect(!isValidHost("host_name"));
|
||||||
|
try testing.expect(!isValidHost("host@domain"));
|
||||||
|
try testing.expect(!isValidHost("host:port"));
|
||||||
}
|
}
|
||||||
|
|
||||||
test "user validation - valid cases" {
|
test "user validation - valid cases" {
|
||||||
|
|
@ -543,7 +518,7 @@ test "cache key validation - hostname format" {
|
||||||
try testing.expect(isValidCacheKey("example.com"));
|
try testing.expect(isValidCacheKey("example.com"));
|
||||||
try testing.expect(isValidCacheKey("sub.example.com"));
|
try testing.expect(isValidCacheKey("sub.example.com"));
|
||||||
try testing.expect(isValidCacheKey("192.168.1.1"));
|
try testing.expect(isValidCacheKey("192.168.1.1"));
|
||||||
try testing.expect(isValidCacheKey("[::1]"));
|
try testing.expect(isValidCacheKey("::1"));
|
||||||
try testing.expect(!isValidCacheKey(""));
|
try testing.expect(!isValidCacheKey(""));
|
||||||
try testing.expect(!isValidCacheKey(".invalid.com"));
|
try testing.expect(!isValidCacheKey(".invalid.com"));
|
||||||
}
|
}
|
||||||
|
|
@ -555,7 +530,7 @@ test "cache key validation - user@hostname format" {
|
||||||
try testing.expect(isValidCacheKey("test-user@192.168.1.1"));
|
try testing.expect(isValidCacheKey("test-user@192.168.1.1"));
|
||||||
try testing.expect(isValidCacheKey("user_name@host.domain.org"));
|
try testing.expect(isValidCacheKey("user_name@host.domain.org"));
|
||||||
try testing.expect(isValidCacheKey("git@github.com"));
|
try testing.expect(isValidCacheKey("git@github.com"));
|
||||||
try testing.expect(isValidCacheKey("ubuntu@[::1]"));
|
try testing.expect(isValidCacheKey("ubuntu@::1"));
|
||||||
try testing.expect(!isValidCacheKey("@example.com"));
|
try testing.expect(!isValidCacheKey("@example.com"));
|
||||||
try testing.expect(!isValidCacheKey("user@"));
|
try testing.expect(!isValidCacheKey("user@"));
|
||||||
try testing.expect(!isValidCacheKey("user@@host"));
|
try testing.expect(!isValidCacheKey("user@@host"));
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ pub const FontStyle = Config.FontStyle;
|
||||||
pub const FreetypeLoadFlags = Config.FreetypeLoadFlags;
|
pub const FreetypeLoadFlags = Config.FreetypeLoadFlags;
|
||||||
pub const Keybinds = Config.Keybinds;
|
pub const Keybinds = Config.Keybinds;
|
||||||
pub const MouseShiftCapture = Config.MouseShiftCapture;
|
pub const MouseShiftCapture = Config.MouseShiftCapture;
|
||||||
|
pub const MouseScrollMultiplier = Config.MouseScrollMultiplier;
|
||||||
pub const NonNativeFullscreen = Config.NonNativeFullscreen;
|
pub const NonNativeFullscreen = Config.NonNativeFullscreen;
|
||||||
pub const OptionAsAlt = Config.OptionAsAlt;
|
pub const OptionAsAlt = Config.OptionAsAlt;
|
||||||
pub const RepeatableCodepointMap = Config.RepeatableCodepointMap;
|
pub const RepeatableCodepointMap = Config.RepeatableCodepointMap;
|
||||||
|
|
|
||||||
|
|
@ -127,9 +127,6 @@ pub const compatibility = std.StaticStringMap(
|
||||||
/// this within config files if you want to clear previously set values in
|
/// this within config files if you want to clear previously set values in
|
||||||
/// configuration files or on the CLI if you want to clear values set on the
|
/// configuration files or on the CLI if you want to clear values set on the
|
||||||
/// CLI.
|
/// CLI.
|
||||||
///
|
|
||||||
/// Changing this configuration at runtime will only affect new terminals, i.e.
|
|
||||||
/// new windows, tabs, etc.
|
|
||||||
@"font-family": RepeatableString = .{},
|
@"font-family": RepeatableString = .{},
|
||||||
@"font-family-bold": RepeatableString = .{},
|
@"font-family-bold": RepeatableString = .{},
|
||||||
@"font-family-italic": RepeatableString = .{},
|
@"font-family-italic": RepeatableString = .{},
|
||||||
|
|
@ -214,11 +211,12 @@ pub const compatibility = std.StaticStringMap(
|
||||||
///
|
///
|
||||||
/// For example, 13.5pt @ 2px/pt = 27px
|
/// For example, 13.5pt @ 2px/pt = 27px
|
||||||
///
|
///
|
||||||
/// Changing this configuration at runtime will only affect new terminals,
|
/// Changing this configuration at runtime will only affect existing
|
||||||
/// i.e. new windows, tabs, etc. Note that you may still not see the change
|
/// terminals that have NOT manually adjusted their font size in some way
|
||||||
/// depending on your `window-inherit-font-size` setting. If that setting is
|
/// (e.g. increased or decreased the font size). Terminals that have manually
|
||||||
/// true, only the first window will be affected by this change since all
|
/// adjusted their font size will retain their manually adjusted size.
|
||||||
/// subsequent windows will inherit the font size of the previous window.
|
/// Otherwise, the font size of existing terminals will be updated on
|
||||||
|
/// reload.
|
||||||
///
|
///
|
||||||
/// On Linux with GTK, font size is scaled according to both display-wide and
|
/// On Linux with GTK, font size is scaled according to both display-wide and
|
||||||
/// text-specific scaling factors, which are often managed by your desktop
|
/// text-specific scaling factors, which are often managed by your desktop
|
||||||
|
|
@ -405,13 +403,13 @@ pub const compatibility = std.StaticStringMap(
|
||||||
@"adjust-box-thickness": ?MetricModifier = null,
|
@"adjust-box-thickness": ?MetricModifier = null,
|
||||||
/// Height in pixels or percentage adjustment of maximum height for nerd font icons.
|
/// Height in pixels or percentage adjustment of maximum height for nerd font icons.
|
||||||
///
|
///
|
||||||
/// Increasing this value will allow nerd font icons to be larger, but won't
|
/// A positive (negative) value will increase (decrease) the maximum icon
|
||||||
/// necessarily force them to be. Decreasing this value will make nerd font
|
/// height. This may not affect all icons equally: the effect depends on whether
|
||||||
/// icons smaller.
|
/// the default size of the icon is height-constrained, which in turn depends on
|
||||||
|
/// the aspect ratio of both the icon and your primary font.
|
||||||
///
|
///
|
||||||
/// The default value for the icon height is 1.2 times the height of capital
|
/// Certain icons designed for box drawing and terminal graphics, such as
|
||||||
/// letters in your primary font, so something like -16.6% would make icons
|
/// Powerline symbols, are not affected by this option.
|
||||||
/// roughly the same height as capital letters.
|
|
||||||
///
|
///
|
||||||
/// See the notes about adjustments in `adjust-cell-width`.
|
/// See the notes about adjustments in `adjust-cell-width`.
|
||||||
///
|
///
|
||||||
|
|
@ -515,7 +513,7 @@ pub const compatibility = std.StaticStringMap(
|
||||||
///
|
///
|
||||||
/// To specify a different theme for light and dark mode, use the following
|
/// To specify a different theme for light and dark mode, use the following
|
||||||
/// syntax: `light:theme-name,dark:theme-name`. For example:
|
/// syntax: `light:theme-name,dark:theme-name`. For example:
|
||||||
/// `light:rose-pine-dawn,dark:rose-pine`. Whitespace around all values are
|
/// `light:Rose Pine Dawn,dark:Rose Pine`. Whitespace around all values are
|
||||||
/// trimmed and order of light and dark does not matter. Both light and dark
|
/// trimmed and order of light and dark does not matter. Both light and dark
|
||||||
/// must be specified in this form. In this form, the theme used will be
|
/// must be specified in this form. In this form, the theme used will be
|
||||||
/// based on the current desktop environment theme.
|
/// based on the current desktop environment theme.
|
||||||
|
|
@ -826,14 +824,20 @@ palette: Palette = .{},
|
||||||
/// * `never`
|
/// * `never`
|
||||||
@"mouse-shift-capture": MouseShiftCapture = .false,
|
@"mouse-shift-capture": MouseShiftCapture = .false,
|
||||||
|
|
||||||
/// Multiplier for scrolling distance with the mouse wheel. Any value less
|
/// Multiplier for scrolling distance with the mouse wheel.
|
||||||
/// than 0.01 or greater than 10,000 will be clamped to the nearest valid
|
|
||||||
/// value.
|
|
||||||
///
|
///
|
||||||
/// A value of "3" (default) scrolls 3 lines per tick.
|
/// A prefix of `precision:` or `discrete:` can be used to set the multiplier
|
||||||
|
/// only for scrolling with the specific type of devices. These can be
|
||||||
|
/// comma-separated to set both types of multipliers at the same time, e.g.
|
||||||
|
/// `precision:0.1,discrete:3`. If no prefix is used, the multiplier applies
|
||||||
|
/// to all scrolling devices. Specifying a prefix was introduced in Ghostty
|
||||||
|
/// 1.2.1.
|
||||||
///
|
///
|
||||||
/// Available since: 1.2.0
|
/// The value will be clamped to [0.01, 10,000]. Both of these are extreme
|
||||||
@"mouse-scroll-multiplier": f64 = 3.0,
|
/// and you're likely to have a bad experience if you set either extreme.
|
||||||
|
///
|
||||||
|
/// The default value is "3" for discrete devices and "1" for precision devices.
|
||||||
|
@"mouse-scroll-multiplier": MouseScrollMultiplier = .default,
|
||||||
|
|
||||||
/// The opacity level (opposite of transparency) of the background. A value of
|
/// The opacity level (opposite of transparency) of the background. A value of
|
||||||
/// 1 is fully opaque and a value of 0 is fully transparent. A value less than 0
|
/// 1 is fully opaque and a value of 0 is fully transparent. A value less than 0
|
||||||
|
|
@ -2146,6 +2150,8 @@ keybind: Keybinds = .{},
|
||||||
/// from the first by a comma (`,`). Percentage and pixel sizes can be mixed
|
/// from the first by a comma (`,`). Percentage and pixel sizes can be mixed
|
||||||
/// together: for instance, a size of `50%,500px` for a top-positioned quick
|
/// together: for instance, a size of `50%,500px` for a top-positioned quick
|
||||||
/// terminal would be half a screen tall, and 500 pixels wide.
|
/// terminal would be half a screen tall, and 500 pixels wide.
|
||||||
|
///
|
||||||
|
/// Available since: 1.2.0
|
||||||
@"quick-terminal-size": QuickTerminalSize = .{},
|
@"quick-terminal-size": QuickTerminalSize = .{},
|
||||||
|
|
||||||
/// The layer of the quick terminal window. The higher the layer,
|
/// The layer of the quick terminal window. The higher the layer,
|
||||||
|
|
@ -2341,6 +2347,11 @@ keybind: Keybinds = .{},
|
||||||
/// cache manually using various arguments.
|
/// cache manually using various arguments.
|
||||||
/// (Available since: 1.2.0)
|
/// (Available since: 1.2.0)
|
||||||
///
|
///
|
||||||
|
/// * `path` - Add Ghostty's binary directory to PATH. This ensures the `ghostty`
|
||||||
|
/// command is available in the shell even if shell init scripts reset PATH.
|
||||||
|
/// This is particularly useful on macOS where PATH is often overridden by
|
||||||
|
/// system scripts. The directory is only added if not already present.
|
||||||
|
///
|
||||||
/// SSH features work independently and can be combined for optimal experience:
|
/// SSH features work independently and can be combined for optimal experience:
|
||||||
/// when both `ssh-env` and `ssh-terminfo` are enabled, Ghostty will install its
|
/// when both `ssh-env` and `ssh-terminfo` are enabled, Ghostty will install its
|
||||||
/// terminfo on remote hosts and use `xterm-ghostty` as TERM, falling back to
|
/// terminfo on remote hosts and use `xterm-ghostty` as TERM, falling back to
|
||||||
|
|
@ -2354,9 +2365,21 @@ keybind: Keybinds = .{},
|
||||||
/// (`:`), and then the specified value. The syntax for actions is identical
|
/// (`:`), and then the specified value. The syntax for actions is identical
|
||||||
/// to the one for keybind actions. Whitespace in between fields is ignored.
|
/// to the one for keybind actions. Whitespace in between fields is ignored.
|
||||||
///
|
///
|
||||||
|
/// If you need to embed commas or any other special characters in the values,
|
||||||
|
/// enclose the value in double quotes and it will be interpreted as a Zig
|
||||||
|
/// string literal. This is also useful for including whitespace at the
|
||||||
|
/// beginning or the end of a value. See the
|
||||||
|
/// [Zig documentation](https://ziglang.org/documentation/master/#Escape-Sequences)
|
||||||
|
/// for more information on string literals. Note that multiline string literals
|
||||||
|
/// are not supported.
|
||||||
|
///
|
||||||
|
/// Double quotes can not be used around the field names.
|
||||||
|
///
|
||||||
/// ```ini
|
/// ```ini
|
||||||
/// command-palette-entry = title:Reset Font Style, action:csi:0m
|
/// command-palette-entry = title:Reset Font Style, action:csi:0m
|
||||||
/// command-palette-entry = title:Crash on Main Thread,description:Causes a crash on the main (UI) thread.,action:crash:main
|
/// command-palette-entry = title:Crash on Main Thread,description:Causes a crash on the main (UI) thread.,action:crash:main
|
||||||
|
/// command-palette-entry = title:Focus Split: Right,description:"Focus the split to the right, if it exists.",action:goto_split:right
|
||||||
|
/// command-palette-entry = title:"Ghostty",description:"Add a little Ghostty to your terminal.",action:"text:\xf0\x9f\x91\xbb"
|
||||||
/// ```
|
/// ```
|
||||||
///
|
///
|
||||||
/// By default, the command palette is preloaded with most actions that might
|
/// By default, the command palette is preloaded with most actions that might
|
||||||
|
|
@ -2710,7 +2733,7 @@ keybind: Keybinds = .{},
|
||||||
///
|
///
|
||||||
/// * `new-tab` - Create a new tab in the current window, or open
|
/// * `new-tab` - Create a new tab in the current window, or open
|
||||||
/// a new window if none exist.
|
/// a new window if none exist.
|
||||||
/// * `new-window` - Create a new window unconditionally.
|
/// * `window` - Create a new window unconditionally.
|
||||||
///
|
///
|
||||||
/// The default value is `new-tab`.
|
/// The default value is `new-tab`.
|
||||||
///
|
///
|
||||||
|
|
@ -2846,10 +2869,7 @@ keybind: Keybinds = .{},
|
||||||
/// Supported formats include PNG, JPEG, and ICNS.
|
/// Supported formats include PNG, JPEG, and ICNS.
|
||||||
///
|
///
|
||||||
/// Defaults to `~/.config/ghostty/Ghostty.icns`
|
/// Defaults to `~/.config/ghostty/Ghostty.icns`
|
||||||
///
|
@"macos-custom-icon": ?[:0]const u8 = null,
|
||||||
/// Note: This configuration is required when `macos-icon` is set to
|
|
||||||
/// `custom`
|
|
||||||
@"macos-custom-icon": ?[]const u8 = null,
|
|
||||||
|
|
||||||
/// The material to use for the frame of the macOS app icon.
|
/// The material to use for the frame of the macOS app icon.
|
||||||
///
|
///
|
||||||
|
|
@ -3342,7 +3362,7 @@ pub fn loadOptionalFile(
|
||||||
fn writeConfigTemplate(path: []const u8) !void {
|
fn writeConfigTemplate(path: []const u8) !void {
|
||||||
log.info("creating template config file: path={s}", .{path});
|
log.info("creating template config file: path={s}", .{path});
|
||||||
if (std.fs.path.dirname(path)) |dir_path| {
|
if (std.fs.path.dirname(path)) |dir_path| {
|
||||||
try std.fs.makeDirAbsolute(dir_path);
|
try std.fs.cwd().makePath(dir_path);
|
||||||
}
|
}
|
||||||
const file = try std.fs.createFileAbsolute(path, .{});
|
const file = try std.fs.createFileAbsolute(path, .{});
|
||||||
defer file.close();
|
defer file.close();
|
||||||
|
|
@ -4056,7 +4076,8 @@ pub fn finalize(self: *Config) !void {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clamp our mouse scroll multiplier
|
// Clamp our mouse scroll multiplier
|
||||||
self.@"mouse-scroll-multiplier" = @min(10_000.0, @max(0.01, self.@"mouse-scroll-multiplier"));
|
self.@"mouse-scroll-multiplier".precision = @min(10_000.0, @max(0.01, self.@"mouse-scroll-multiplier".precision));
|
||||||
|
self.@"mouse-scroll-multiplier".discrete = @min(10_000.0, @max(0.01, self.@"mouse-scroll-multiplier".discrete));
|
||||||
|
|
||||||
// Clamp our split opacity
|
// Clamp our split opacity
|
||||||
self.@"unfocused-split-opacity" = @min(1.0, @max(0.15, self.@"unfocused-split-opacity"));
|
self.@"unfocused-split-opacity" = @min(1.0, @max(0.15, self.@"unfocused-split-opacity"));
|
||||||
|
|
@ -6487,7 +6508,7 @@ pub const RepeatableCodepointMap = struct {
|
||||||
return .{ .map = try self.map.clone(alloc) };
|
return .{ .map = try self.map.clone(alloc) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compare if two of our value are requal. Required by Config.
|
/// Compare if two of our value are equal. Required by Config.
|
||||||
pub fn equal(self: Self, other: Self) bool {
|
pub fn equal(self: Self, other: Self) bool {
|
||||||
const itemsA = self.map.list.slice();
|
const itemsA = self.map.list.slice();
|
||||||
const itemsB = other.map.list.slice();
|
const itemsB = other.map.list.slice();
|
||||||
|
|
@ -6963,6 +6984,7 @@ pub const ShellIntegrationFeatures = packed struct {
|
||||||
title: bool = true,
|
title: bool = true,
|
||||||
@"ssh-env": bool = false,
|
@"ssh-env": bool = false,
|
||||||
@"ssh-terminfo": bool = false,
|
@"ssh-terminfo": bool = false,
|
||||||
|
path: bool = true,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const RepeatableCommand = struct {
|
pub const RepeatableCommand = struct {
|
||||||
|
|
@ -6989,6 +7011,7 @@ pub const RepeatableCommand = struct {
|
||||||
inputpkg.Command,
|
inputpkg.Command,
|
||||||
alloc,
|
alloc,
|
||||||
input,
|
input,
|
||||||
|
null,
|
||||||
);
|
);
|
||||||
try self.value.append(alloc, cmd);
|
try self.value.append(alloc, cmd);
|
||||||
}
|
}
|
||||||
|
|
@ -7020,18 +7043,24 @@ pub const RepeatableCommand = struct {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var buf: [4096]u8 = undefined;
|
|
||||||
for (self.value.items) |item| {
|
for (self.value.items) |item| {
|
||||||
const str = if (item.description.len > 0) std.fmt.bufPrint(
|
var buf: [4096]u8 = undefined;
|
||||||
&buf,
|
var fbs = std.io.fixedBufferStream(&buf);
|
||||||
"title:{s},description:{s},action:{}",
|
var writer = fbs.writer();
|
||||||
.{ item.title, item.description, item.action },
|
|
||||||
) else std.fmt.bufPrint(
|
writer.writeAll("title:\"") catch return error.OutOfMemory;
|
||||||
&buf,
|
std.zig.stringEscape(item.title, "", .{}, writer) catch return error.OutOfMemory;
|
||||||
"title:{s},action:{}",
|
writer.writeAll("\"") catch return error.OutOfMemory;
|
||||||
.{ item.title, item.action },
|
|
||||||
);
|
if (item.description.len > 0) {
|
||||||
try formatter.formatEntry([]const u8, str catch return error.OutOfMemory);
|
writer.writeAll(",description:\"") catch return error.OutOfMemory;
|
||||||
|
std.zig.stringEscape(item.description, "", .{}, writer) catch return error.OutOfMemory;
|
||||||
|
writer.writeAll("\"") catch return error.OutOfMemory;
|
||||||
|
}
|
||||||
|
|
||||||
|
writer.print(",action:\"{}\"", .{item.action}) catch return error.OutOfMemory;
|
||||||
|
|
||||||
|
try formatter.formatEntry([]const u8, fbs.getWritten());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -7097,7 +7126,7 @@ pub const RepeatableCommand = struct {
|
||||||
var list: RepeatableCommand = .{};
|
var list: RepeatableCommand = .{};
|
||||||
try list.parseCLI(alloc, "title:Bobr, action:text:Bober");
|
try list.parseCLI(alloc, "title:Bobr, action:text:Bober");
|
||||||
try list.formatEntry(formatterpkg.entryFormatter("a", buf.writer()));
|
try list.formatEntry(formatterpkg.entryFormatter("a", buf.writer()));
|
||||||
try std.testing.expectEqualSlices(u8, "a = title:Bobr,action:text:Bober\n", buf.items);
|
try std.testing.expectEqualSlices(u8, "a = title:\"Bobr\",action:\"text:Bober\"\n", buf.items);
|
||||||
}
|
}
|
||||||
|
|
||||||
test "RepeatableCommand formatConfig multiple items" {
|
test "RepeatableCommand formatConfig multiple items" {
|
||||||
|
|
@ -7113,7 +7142,40 @@ pub const RepeatableCommand = struct {
|
||||||
try list.parseCLI(alloc, "title:Bobr, action:text:kurwa");
|
try list.parseCLI(alloc, "title:Bobr, action:text:kurwa");
|
||||||
try list.parseCLI(alloc, "title:Ja, description: pierdole, action:text:jakie bydle");
|
try list.parseCLI(alloc, "title:Ja, description: pierdole, action:text:jakie bydle");
|
||||||
try list.formatEntry(formatterpkg.entryFormatter("a", buf.writer()));
|
try list.formatEntry(formatterpkg.entryFormatter("a", buf.writer()));
|
||||||
try std.testing.expectEqualSlices(u8, "a = title:Bobr,action:text:kurwa\na = title:Ja,description:pierdole,action:text:jakie bydle\n", buf.items);
|
try std.testing.expectEqualSlices(u8, "a = title:\"Bobr\",action:\"text:kurwa\"\na = title:\"Ja\",description:\"pierdole\",action:\"text:jakie bydle\"\n", buf.items);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "RepeatableCommand parseCLI commas" {
|
||||||
|
const testing = std.testing;
|
||||||
|
var buf = std.ArrayList(u8).init(testing.allocator);
|
||||||
|
defer buf.deinit();
|
||||||
|
|
||||||
|
var arena = ArenaAllocator.init(testing.allocator);
|
||||||
|
defer arena.deinit();
|
||||||
|
const alloc = arena.allocator();
|
||||||
|
|
||||||
|
{
|
||||||
|
var list: RepeatableCommand = .{};
|
||||||
|
try list.parseCLI(alloc, "title:\"Bo,br\",action:\"text:kur,wa\"");
|
||||||
|
try testing.expectEqual(@as(usize, 1), list.value.items.len);
|
||||||
|
|
||||||
|
const item = list.value.items[0];
|
||||||
|
try testing.expectEqualStrings("Bo,br", item.title);
|
||||||
|
try testing.expectEqualStrings("", item.description);
|
||||||
|
try testing.expect(item.action == .text);
|
||||||
|
try testing.expectEqualStrings("kur,wa", item.action.text);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
var list: RepeatableCommand = .{};
|
||||||
|
try list.parseCLI(alloc, "title:\"Bo,br\",description:\"abc,def\",action:text:kurwa");
|
||||||
|
try testing.expectEqual(@as(usize, 1), list.value.items.len);
|
||||||
|
|
||||||
|
const item = list.value.items[0];
|
||||||
|
try testing.expectEqualStrings("Bo,br", item.title);
|
||||||
|
try testing.expectEqualStrings("abc,def", item.description);
|
||||||
|
try testing.expect(item.action == .text);
|
||||||
|
try testing.expectEqualStrings("kurwa", item.action.text);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -7259,6 +7321,108 @@ pub const MouseShiftCapture = enum {
|
||||||
never,
|
never,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// See mouse-scroll-multiplier
|
||||||
|
pub const MouseScrollMultiplier = struct {
|
||||||
|
const Self = @This();
|
||||||
|
|
||||||
|
precision: f64 = 1,
|
||||||
|
discrete: f64 = 3,
|
||||||
|
|
||||||
|
pub const default: MouseScrollMultiplier = .{};
|
||||||
|
|
||||||
|
pub fn parseCLI(self: *Self, alloc: Allocator, input_: ?[]const u8) !void {
|
||||||
|
const input = input_ orelse return error.ValueRequired;
|
||||||
|
self.* = cli.args.parseAutoStruct(
|
||||||
|
MouseScrollMultiplier,
|
||||||
|
alloc,
|
||||||
|
input,
|
||||||
|
self.*,
|
||||||
|
) catch |err| switch (err) {
|
||||||
|
error.InvalidValue => bare: {
|
||||||
|
const v = std.fmt.parseFloat(
|
||||||
|
f64,
|
||||||
|
input,
|
||||||
|
) catch return error.InvalidValue;
|
||||||
|
break :bare .{
|
||||||
|
.precision = v,
|
||||||
|
.discrete = v,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
else => return err,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deep copy of the struct. Required by Config.
|
||||||
|
pub fn clone(self: *const Self, alloc: Allocator) Allocator.Error!Self {
|
||||||
|
_ = alloc;
|
||||||
|
return self.*;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compare if two of our value are equal. Required by Config.
|
||||||
|
pub fn equal(self: Self, other: Self) bool {
|
||||||
|
return self.precision == other.precision and self.discrete == other.discrete;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Used by Formatter
|
||||||
|
pub fn formatEntry(self: Self, formatter: anytype) !void {
|
||||||
|
var buf: [32]u8 = undefined;
|
||||||
|
const formatted = std.fmt.bufPrint(
|
||||||
|
&buf,
|
||||||
|
"precision:{d},discrete:{d}",
|
||||||
|
.{ self.precision, self.discrete },
|
||||||
|
) catch return error.OutOfMemory;
|
||||||
|
try formatter.formatEntry([]const u8, formatted);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "parse" {
|
||||||
|
const testing = std.testing;
|
||||||
|
const alloc = testing.allocator;
|
||||||
|
const epsilon = 0.00001;
|
||||||
|
|
||||||
|
var args: Self = .{ .precision = 0.1, .discrete = 3 };
|
||||||
|
try args.parseCLI(alloc, "3");
|
||||||
|
try testing.expectApproxEqAbs(3, args.precision, epsilon);
|
||||||
|
try testing.expectApproxEqAbs(3, args.discrete, epsilon);
|
||||||
|
|
||||||
|
args = .{ .precision = 0.1, .discrete = 3 };
|
||||||
|
try args.parseCLI(alloc, "precision:1");
|
||||||
|
try testing.expectApproxEqAbs(1, args.precision, epsilon);
|
||||||
|
try testing.expectApproxEqAbs(3, args.discrete, epsilon);
|
||||||
|
|
||||||
|
args = .{ .precision = 0.1, .discrete = 3 };
|
||||||
|
try args.parseCLI(alloc, "discrete:5");
|
||||||
|
try testing.expectApproxEqAbs(0.1, args.precision, epsilon);
|
||||||
|
try testing.expectApproxEqAbs(5, args.discrete, epsilon);
|
||||||
|
|
||||||
|
args = .{ .precision = 0.1, .discrete = 3 };
|
||||||
|
try args.parseCLI(alloc, "precision:3,discrete:7");
|
||||||
|
try testing.expectApproxEqAbs(3, args.precision, epsilon);
|
||||||
|
try testing.expectApproxEqAbs(7, args.discrete, epsilon);
|
||||||
|
|
||||||
|
args = .{ .precision = 0.1, .discrete = 3 };
|
||||||
|
try args.parseCLI(alloc, "discrete:8,precision:6");
|
||||||
|
try testing.expectApproxEqAbs(6, args.precision, epsilon);
|
||||||
|
try testing.expectApproxEqAbs(8, args.discrete, epsilon);
|
||||||
|
|
||||||
|
args = .default;
|
||||||
|
try testing.expectError(error.InvalidValue, args.parseCLI(alloc, "foo:1"));
|
||||||
|
try testing.expectError(error.InvalidValue, args.parseCLI(alloc, "precision:bar"));
|
||||||
|
try testing.expectError(error.InvalidValue, args.parseCLI(alloc, "precision:1,discrete:3,foo:5"));
|
||||||
|
try testing.expectError(error.InvalidValue, args.parseCLI(alloc, "precision:1,,discrete:3"));
|
||||||
|
try testing.expectError(error.InvalidValue, args.parseCLI(alloc, ",precision:1,discrete:3"));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "format entry MouseScrollMultiplier" {
|
||||||
|
const testing = std.testing;
|
||||||
|
var buf = std.ArrayList(u8).init(testing.allocator);
|
||||||
|
defer buf.deinit();
|
||||||
|
|
||||||
|
var args: Self = .{ .precision = 1.5, .discrete = 2.5 };
|
||||||
|
try args.formatEntry(formatterpkg.entryFormatter("mouse-scroll-multiplier", buf.writer()));
|
||||||
|
try testing.expectEqualSlices(u8, "mouse-scroll-multiplier = precision:1.5,discrete:2.5\n", buf.items);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/// How to treat requests to write to or read from the clipboard
|
/// How to treat requests to write to or read from the clipboard
|
||||||
pub const ClipboardAccess = enum {
|
pub const ClipboardAccess = enum {
|
||||||
allow,
|
allow,
|
||||||
|
|
@ -7873,6 +8037,7 @@ pub const Theme = struct {
|
||||||
Theme,
|
Theme,
|
||||||
alloc,
|
alloc,
|
||||||
input,
|
input,
|
||||||
|
null,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ pub const ftdetect =
|
||||||
\\"
|
\\"
|
||||||
\\" THIS FILE IS AUTO-GENERATED
|
\\" THIS FILE IS AUTO-GENERATED
|
||||||
\\
|
\\
|
||||||
\\au BufRead,BufNewFile */ghostty/config,*/ghostty/themes/* set ft=ghostty
|
\\au BufRead,BufNewFile */ghostty/config,*/ghostty/themes/* setf ghostty
|
||||||
\\
|
\\
|
||||||
;
|
;
|
||||||
pub const ftplugin =
|
pub const ftplugin =
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,147 @@
|
||||||
|
// The contents of this file is largely based on testing.zig from the Zig 0.15.1
|
||||||
|
// stdlib, distributed under the MIT license, copyright (c) Zig contributors
|
||||||
|
const std = @import("std");
|
||||||
|
|
||||||
|
/// Generic, recursive equality testing utility using approximate comparison for
|
||||||
|
/// floats and equality for everything else
|
||||||
|
///
|
||||||
|
/// Based on `std.testing.expectEqual` and `std.testing.expectEqualSlices`.
|
||||||
|
///
|
||||||
|
/// The relative tolerance is currently hardcoded to `sqrt(eps(float_type))`.
|
||||||
|
pub inline fn expectApproxEqual(expected: anytype, actual: anytype) !void {
|
||||||
|
const T = @TypeOf(expected, actual);
|
||||||
|
return expectApproxEqualInner(T, expected, actual);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn expectApproxEqualInner(comptime T: type, expected: T, actual: T) !void {
|
||||||
|
switch (@typeInfo(T)) {
|
||||||
|
// check approximate equality for floats
|
||||||
|
.float => {
|
||||||
|
const sqrt_eps = comptime std.math.sqrt(std.math.floatEps(T));
|
||||||
|
if (!std.math.approxEqRel(T, expected, actual, sqrt_eps)) {
|
||||||
|
print("expected approximately {any}, found {any}\n", .{ expected, actual });
|
||||||
|
return error.TestExpectedApproxEqual;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// recurse into containers
|
||||||
|
.array => {
|
||||||
|
const diff_index: usize = diff_index: {
|
||||||
|
const shortest = @min(expected.len, actual.len);
|
||||||
|
var index: usize = 0;
|
||||||
|
while (index < shortest) : (index += 1) {
|
||||||
|
expectApproxEqual(actual[index], expected[index]) catch break :diff_index index;
|
||||||
|
}
|
||||||
|
break :diff_index if (expected.len == actual.len) return else shortest;
|
||||||
|
};
|
||||||
|
print("slices not approximately equal. first significant difference occurs at index {d} (0x{X})\n", .{ diff_index, diff_index });
|
||||||
|
return error.TestExpectedApproxEqual;
|
||||||
|
},
|
||||||
|
.vector => |info| {
|
||||||
|
var i: usize = 0;
|
||||||
|
while (i < info.len) : (i += 1) {
|
||||||
|
expectApproxEqual(expected[i], actual[i]) catch {
|
||||||
|
print("index {d} incorrect. expected approximately {any}, found {any}\n", .{
|
||||||
|
i, expected[i], actual[i],
|
||||||
|
});
|
||||||
|
return error.TestExpectedApproxEqual;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
.@"struct" => |structType| {
|
||||||
|
inline for (structType.fields) |field| {
|
||||||
|
try expectApproxEqual(@field(expected, field.name), @field(actual, field.name));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// unwrap unions, optionals, and error unions
|
||||||
|
.@"union" => |union_info| {
|
||||||
|
if (union_info.tag_type == null) {
|
||||||
|
// untagged unions can only be compared bitwise,
|
||||||
|
// so expectEqual is all we need
|
||||||
|
std.testing.expectEqual(expected, actual) catch {
|
||||||
|
return error.TestExpectedApproxEqual;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const Tag = std.meta.Tag(@TypeOf(expected));
|
||||||
|
|
||||||
|
const expectedTag = @as(Tag, expected);
|
||||||
|
const actualTag = @as(Tag, actual);
|
||||||
|
|
||||||
|
std.testing.expectEqual(expectedTag, actualTag) catch {
|
||||||
|
return error.TestExpectedApproxEqual;
|
||||||
|
};
|
||||||
|
|
||||||
|
// we only reach this switch if the tags are equal
|
||||||
|
switch (expected) {
|
||||||
|
inline else => |val, tag| try expectApproxEqual(val, @field(actual, @tagName(tag))),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
.optional, .error_union => {
|
||||||
|
if (expected) |expected_payload| if (actual) |actual_payload| {
|
||||||
|
return expectApproxEqual(expected_payload, actual_payload);
|
||||||
|
};
|
||||||
|
// we only reach this point if there's at least one null or error,
|
||||||
|
// in which case expectEqual is all we need
|
||||||
|
std.testing.expectEqual(expected, actual) catch {
|
||||||
|
return error.TestExpectedApproxEqual;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// fall back to expectEqual for everything else
|
||||||
|
else => std.testing.expectEqual(expected, actual) catch {
|
||||||
|
return error.TestExpectedApproxEqual;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy of std.testing.print (not public)
|
||||||
|
fn print(comptime fmt: []const u8, args: anytype) void {
|
||||||
|
if (@inComptime()) {
|
||||||
|
@compileError(std.fmt.comptimePrint(fmt, args));
|
||||||
|
} else if (std.testing.backend_can_print) {
|
||||||
|
std.debug.print(fmt, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tests based on the `expectEqual` tests in the Zig stdlib
|
||||||
|
test "expectApproxEqual.union(enum)" {
|
||||||
|
const T = union(enum) {
|
||||||
|
a: i32,
|
||||||
|
b: f32,
|
||||||
|
};
|
||||||
|
|
||||||
|
const b10 = T{ .b = 10.0 };
|
||||||
|
const b10plus = T{ .b = 10.000001 };
|
||||||
|
|
||||||
|
try expectApproxEqual(b10, b10plus);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "expectApproxEqual nested array" {
|
||||||
|
const a = [2][2]f32{
|
||||||
|
[_]f32{ 1.0, 0.0 },
|
||||||
|
[_]f32{ 0.0, 1.0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const b = [2][2]f32{
|
||||||
|
[_]f32{ 1.000001, 0.0 },
|
||||||
|
[_]f32{ 0.0, 0.999999 },
|
||||||
|
};
|
||||||
|
|
||||||
|
try expectApproxEqual(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "expectApproxEqual vector" {
|
||||||
|
const a: @Vector(4, f32) = @splat(4.0);
|
||||||
|
const b: @Vector(4, f32) = @splat(4.000001);
|
||||||
|
|
||||||
|
try expectApproxEqual(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "expectApproxEqual struct" {
|
||||||
|
const a = .{ 1, @as(f32, 1.0) };
|
||||||
|
const b = .{ 1, @as(f32, 0.999999) };
|
||||||
|
|
||||||
|
try expectApproxEqual(a, b);
|
||||||
|
}
|
||||||
|
|
@ -23,7 +23,7 @@ pub fn DoublyLinkedList(comptime T: type) type {
|
||||||
/// Arguments:
|
/// Arguments:
|
||||||
/// node: Pointer to a node in the list.
|
/// node: Pointer to a node in the list.
|
||||||
/// new_node: Pointer to the new node to insert.
|
/// new_node: Pointer to the new node to insert.
|
||||||
pub fn insertAfter(list: *Self, node: *Node, new_node: *Node) void {
|
pub inline fn insertAfter(list: *Self, node: *Node, new_node: *Node) void {
|
||||||
new_node.prev = node;
|
new_node.prev = node;
|
||||||
if (node.next) |next_node| {
|
if (node.next) |next_node| {
|
||||||
// Intermediate node.
|
// Intermediate node.
|
||||||
|
|
@ -42,7 +42,7 @@ pub fn DoublyLinkedList(comptime T: type) type {
|
||||||
/// Arguments:
|
/// Arguments:
|
||||||
/// node: Pointer to a node in the list.
|
/// node: Pointer to a node in the list.
|
||||||
/// new_node: Pointer to the new node to insert.
|
/// new_node: Pointer to the new node to insert.
|
||||||
pub fn insertBefore(list: *Self, node: *Node, new_node: *Node) void {
|
pub inline fn insertBefore(list: *Self, node: *Node, new_node: *Node) void {
|
||||||
new_node.next = node;
|
new_node.next = node;
|
||||||
if (node.prev) |prev_node| {
|
if (node.prev) |prev_node| {
|
||||||
// Intermediate node.
|
// Intermediate node.
|
||||||
|
|
@ -60,7 +60,7 @@ pub fn DoublyLinkedList(comptime T: type) type {
|
||||||
///
|
///
|
||||||
/// Arguments:
|
/// Arguments:
|
||||||
/// new_node: Pointer to the new node to insert.
|
/// new_node: Pointer to the new node to insert.
|
||||||
pub fn append(list: *Self, new_node: *Node) void {
|
pub inline fn append(list: *Self, new_node: *Node) void {
|
||||||
if (list.last) |last| {
|
if (list.last) |last| {
|
||||||
// Insert after last.
|
// Insert after last.
|
||||||
list.insertAfter(last, new_node);
|
list.insertAfter(last, new_node);
|
||||||
|
|
@ -74,7 +74,7 @@ pub fn DoublyLinkedList(comptime T: type) type {
|
||||||
///
|
///
|
||||||
/// Arguments:
|
/// Arguments:
|
||||||
/// new_node: Pointer to the new node to insert.
|
/// new_node: Pointer to the new node to insert.
|
||||||
pub fn prepend(list: *Self, new_node: *Node) void {
|
pub inline fn prepend(list: *Self, new_node: *Node) void {
|
||||||
if (list.first) |first| {
|
if (list.first) |first| {
|
||||||
// Insert before first.
|
// Insert before first.
|
||||||
list.insertBefore(first, new_node);
|
list.insertBefore(first, new_node);
|
||||||
|
|
@ -91,7 +91,7 @@ pub fn DoublyLinkedList(comptime T: type) type {
|
||||||
///
|
///
|
||||||
/// Arguments:
|
/// Arguments:
|
||||||
/// node: Pointer to the node to be removed.
|
/// node: Pointer to the node to be removed.
|
||||||
pub fn remove(list: *Self, node: *Node) void {
|
pub inline fn remove(list: *Self, node: *Node) void {
|
||||||
if (node.prev) |prev_node| {
|
if (node.prev) |prev_node| {
|
||||||
// Intermediate node.
|
// Intermediate node.
|
||||||
prev_node.next = node.next;
|
prev_node.next = node.next;
|
||||||
|
|
@ -113,7 +113,7 @@ pub fn DoublyLinkedList(comptime T: type) type {
|
||||||
///
|
///
|
||||||
/// Returns:
|
/// Returns:
|
||||||
/// A pointer to the last node in the list.
|
/// A pointer to the last node in the list.
|
||||||
pub fn pop(list: *Self) ?*Node {
|
pub inline fn pop(list: *Self) ?*Node {
|
||||||
const last = list.last orelse return null;
|
const last = list.last orelse return null;
|
||||||
list.remove(last);
|
list.remove(last);
|
||||||
return last;
|
return last;
|
||||||
|
|
@ -123,7 +123,7 @@ pub fn DoublyLinkedList(comptime T: type) type {
|
||||||
///
|
///
|
||||||
/// Returns:
|
/// Returns:
|
||||||
/// A pointer to the first node in the list.
|
/// A pointer to the first node in the list.
|
||||||
pub fn popFirst(list: *Self) ?*Node {
|
pub inline fn popFirst(list: *Self) ?*Node {
|
||||||
const first = list.first orelse return null;
|
const first = list.first orelse return null;
|
||||||
list.remove(first);
|
list.remove(first);
|
||||||
return first;
|
return first;
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ const std = @import("std");
|
||||||
const assert = std.debug.assert;
|
const assert = std.debug.assert;
|
||||||
const Allocator = std.mem.Allocator;
|
const Allocator = std.mem.Allocator;
|
||||||
const config = @import("../config.zig");
|
const config = @import("../config.zig");
|
||||||
|
const comparison = @import("../datastruct/comparison.zig");
|
||||||
const font = @import("main.zig");
|
const font = @import("main.zig");
|
||||||
const options = font.options;
|
const options = font.options;
|
||||||
const DeferredFace = font.DeferredFace;
|
const DeferredFace = font.DeferredFace;
|
||||||
|
|
@ -1199,7 +1200,7 @@ test "metrics" {
|
||||||
|
|
||||||
try c.updateMetrics();
|
try c.updateMetrics();
|
||||||
|
|
||||||
try std.testing.expectEqual(font.Metrics{
|
try comparison.expectApproxEqual(font.Metrics{
|
||||||
.cell_width = 8,
|
.cell_width = 8,
|
||||||
// The cell height is 17 px because the calculation is
|
// The cell height is 17 px because the calculation is
|
||||||
//
|
//
|
||||||
|
|
@ -1213,6 +1214,9 @@ test "metrics" {
|
||||||
// and 1em should be the point size * dpi scale, so 12 * (96/72)
|
// and 1em should be the point size * dpi scale, so 12 * (96/72)
|
||||||
// which is 16, and 16 * 1.049 = 16.784, which finally is rounded
|
// which is 16, and 16 * 1.049 = 16.784, which finally is rounded
|
||||||
// to 17.
|
// to 17.
|
||||||
|
//
|
||||||
|
// The icon height is (2 * cap_height + face_height) / 3
|
||||||
|
// = (2 * 623 + 1049) / 3 = 765, and 16 * 0.765 = 12.24.
|
||||||
.cell_height = 17,
|
.cell_height = 17,
|
||||||
.cell_baseline = 3,
|
.cell_baseline = 3,
|
||||||
.underline_position = 17,
|
.underline_position = 17,
|
||||||
|
|
@ -1223,12 +1227,16 @@ test "metrics" {
|
||||||
.overline_thickness = 1,
|
.overline_thickness = 1,
|
||||||
.box_thickness = 1,
|
.box_thickness = 1,
|
||||||
.cursor_height = 17,
|
.cursor_height = 17,
|
||||||
.icon_height = 11,
|
.icon_height = 16.784,
|
||||||
|
.icon_height_single = 12.24,
|
||||||
|
.face_width = 8.0,
|
||||||
|
.face_height = 16.784,
|
||||||
|
.face_y = -0.04,
|
||||||
}, c.metrics);
|
}, c.metrics);
|
||||||
|
|
||||||
// Resize should change metrics
|
// Resize should change metrics
|
||||||
try c.setSize(.{ .points = 24, .xdpi = 96, .ydpi = 96 });
|
try c.setSize(.{ .points = 24, .xdpi = 96, .ydpi = 96 });
|
||||||
try std.testing.expectEqual(font.Metrics{
|
try comparison.expectApproxEqual(font.Metrics{
|
||||||
.cell_width = 16,
|
.cell_width = 16,
|
||||||
.cell_height = 34,
|
.cell_height = 34,
|
||||||
.cell_baseline = 6,
|
.cell_baseline = 6,
|
||||||
|
|
@ -1240,7 +1248,11 @@ test "metrics" {
|
||||||
.overline_thickness = 2,
|
.overline_thickness = 2,
|
||||||
.box_thickness = 2,
|
.box_thickness = 2,
|
||||||
.cursor_height = 34,
|
.cursor_height = 34,
|
||||||
.icon_height = 23,
|
.icon_height = 33.568,
|
||||||
|
.icon_height_single = 24.48,
|
||||||
|
.face_width = 16.0,
|
||||||
|
.face_height = 33.568,
|
||||||
|
.face_y = -0.08,
|
||||||
}, c.metrics);
|
}, c.metrics);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1369,3 +1381,133 @@ test "adjusted sizes" {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test "face metrics" {
|
||||||
|
// The web canvas backend doesn't calculate face metrics, only cell metrics
|
||||||
|
if (options.backend != .web_canvas) return error.SkipZigTest;
|
||||||
|
|
||||||
|
const testing = std.testing;
|
||||||
|
const alloc = testing.allocator;
|
||||||
|
const narrowFont = font.embedded.cozette;
|
||||||
|
const wideFont = font.embedded.geist_mono;
|
||||||
|
|
||||||
|
var lib = try Library.init(alloc);
|
||||||
|
defer lib.deinit();
|
||||||
|
|
||||||
|
var c = init();
|
||||||
|
defer c.deinit(alloc);
|
||||||
|
const size: DesiredSize = .{ .points = 12, .xdpi = 96, .ydpi = 96 };
|
||||||
|
c.load_options = .{ .library = lib, .size = size };
|
||||||
|
|
||||||
|
const narrowIndex = try c.add(alloc, try .init(
|
||||||
|
lib,
|
||||||
|
narrowFont,
|
||||||
|
.{ .size = size },
|
||||||
|
), .{
|
||||||
|
.style = .regular,
|
||||||
|
.fallback = false,
|
||||||
|
.size_adjustment = .none,
|
||||||
|
});
|
||||||
|
const wideIndex = try c.add(alloc, try .init(
|
||||||
|
lib,
|
||||||
|
wideFont,
|
||||||
|
.{ .size = size },
|
||||||
|
), .{
|
||||||
|
.style = .regular,
|
||||||
|
.fallback = false,
|
||||||
|
.size_adjustment = .none,
|
||||||
|
});
|
||||||
|
|
||||||
|
const narrowMetrics: font.Metrics.FaceMetrics = (try c.getFace(narrowIndex)).getMetrics();
|
||||||
|
const wideMetrics: font.Metrics.FaceMetrics = (try c.getFace(wideIndex)).getMetrics();
|
||||||
|
|
||||||
|
// Verify provided/measured metrics. Measured
|
||||||
|
// values are backend-dependent due to hinting.
|
||||||
|
const narrowMetricsExpected = font.Metrics.FaceMetrics{
|
||||||
|
.px_per_em = 16.0,
|
||||||
|
.cell_width = switch (options.backend) {
|
||||||
|
.freetype,
|
||||||
|
.fontconfig_freetype,
|
||||||
|
.coretext_freetype,
|
||||||
|
=> 8.0,
|
||||||
|
.coretext,
|
||||||
|
.coretext_harfbuzz,
|
||||||
|
.coretext_noshape,
|
||||||
|
=> 7.3828125,
|
||||||
|
.web_canvas => unreachable,
|
||||||
|
},
|
||||||
|
.ascent = 12.3046875,
|
||||||
|
.descent = -3.6953125,
|
||||||
|
.line_gap = 0.0,
|
||||||
|
.underline_position = -1.2265625,
|
||||||
|
.underline_thickness = 1.2265625,
|
||||||
|
.strikethrough_position = 6.15625,
|
||||||
|
.strikethrough_thickness = 1.234375,
|
||||||
|
.cap_height = 9.84375,
|
||||||
|
.ex_height = 7.3828125,
|
||||||
|
.ascii_height = switch (options.backend) {
|
||||||
|
.freetype,
|
||||||
|
.fontconfig_freetype,
|
||||||
|
.coretext_freetype,
|
||||||
|
=> 18.0625,
|
||||||
|
.coretext,
|
||||||
|
.coretext_harfbuzz,
|
||||||
|
.coretext_noshape,
|
||||||
|
=> 16.0,
|
||||||
|
.web_canvas => unreachable,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const wideMetricsExpected = font.Metrics.FaceMetrics{
|
||||||
|
.px_per_em = 16.0,
|
||||||
|
.cell_width = switch (options.backend) {
|
||||||
|
.freetype,
|
||||||
|
.fontconfig_freetype,
|
||||||
|
.coretext_freetype,
|
||||||
|
=> 10.0,
|
||||||
|
.coretext,
|
||||||
|
.coretext_harfbuzz,
|
||||||
|
.coretext_noshape,
|
||||||
|
=> 9.6,
|
||||||
|
.web_canvas => unreachable,
|
||||||
|
},
|
||||||
|
.ascent = 14.72,
|
||||||
|
.descent = -3.52,
|
||||||
|
.line_gap = 1.6,
|
||||||
|
.underline_position = -1.6,
|
||||||
|
.underline_thickness = 0.8,
|
||||||
|
.strikethrough_position = 4.24,
|
||||||
|
.strikethrough_thickness = 0.8,
|
||||||
|
.cap_height = 11.36,
|
||||||
|
.ex_height = 8.48,
|
||||||
|
.ascii_height = switch (options.backend) {
|
||||||
|
.freetype,
|
||||||
|
.fontconfig_freetype,
|
||||||
|
.coretext_freetype,
|
||||||
|
=> 16.0,
|
||||||
|
.coretext,
|
||||||
|
.coretext_harfbuzz,
|
||||||
|
.coretext_noshape,
|
||||||
|
=> 15.472000000000001,
|
||||||
|
.web_canvas => unreachable,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
inline for (
|
||||||
|
.{ narrowMetricsExpected, wideMetricsExpected },
|
||||||
|
.{ narrowMetrics, wideMetrics },
|
||||||
|
) |metricsExpected, metricsActual| {
|
||||||
|
try comparison.expectApproxEqual(metricsExpected, metricsActual);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify estimated metrics. icWidth() should equal the smaller of
|
||||||
|
// 2 * cell_width and ascii_height. For a narrow (wide) font, the
|
||||||
|
// smaller quantity is the former (latter).
|
||||||
|
try std.testing.expectEqual(
|
||||||
|
2 * narrowMetrics.cell_width,
|
||||||
|
narrowMetrics.icWidth(),
|
||||||
|
);
|
||||||
|
try std.testing.expectEqual(
|
||||||
|
wideMetrics.ascii_height,
|
||||||
|
wideMetrics.icWidth(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,11 +36,20 @@ cursor_thickness: u32 = 1,
|
||||||
cursor_height: u32,
|
cursor_height: u32,
|
||||||
|
|
||||||
/// The constraint height for nerd fonts icons.
|
/// The constraint height for nerd fonts icons.
|
||||||
icon_height: u32,
|
icon_height: f64,
|
||||||
|
|
||||||
/// Original cell width in pixels. This is used to keep
|
/// The constraint height for nerd fonts icons limited to a single cell width.
|
||||||
/// glyphs centered if the cell width is adjusted wider.
|
icon_height_single: f64,
|
||||||
original_cell_width: ?u32 = null,
|
|
||||||
|
/// The unrounded face width, used in scaling calculations.
|
||||||
|
face_width: f64,
|
||||||
|
|
||||||
|
/// The unrounded face height, used in scaling calculations.
|
||||||
|
face_height: f64,
|
||||||
|
|
||||||
|
/// The vertical bearing of face within the pixel-rounded
|
||||||
|
/// and possibly height-adjusted cell
|
||||||
|
face_y: f64,
|
||||||
|
|
||||||
/// Minimum acceptable values for some fields to prevent modifiers
|
/// Minimum acceptable values for some fields to prevent modifiers
|
||||||
/// from being able to, for example, cause 0-thickness underlines.
|
/// from being able to, for example, cause 0-thickness underlines.
|
||||||
|
|
@ -53,7 +62,10 @@ const Minimums = struct {
|
||||||
const box_thickness = 1;
|
const box_thickness = 1;
|
||||||
const cursor_thickness = 1;
|
const cursor_thickness = 1;
|
||||||
const cursor_height = 1;
|
const cursor_height = 1;
|
||||||
const icon_height = 1;
|
const icon_height = 1.0;
|
||||||
|
const icon_height_single = 1.0;
|
||||||
|
const face_height = 1.0;
|
||||||
|
const face_width = 1.0;
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Metrics extracted from a font face, based on
|
/// Metrics extracted from a font face, based on
|
||||||
|
|
@ -117,6 +129,16 @@ pub const FaceMetrics = struct {
|
||||||
/// lowercase x glyph.
|
/// lowercase x glyph.
|
||||||
ex_height: ?f64 = null,
|
ex_height: ?f64 = null,
|
||||||
|
|
||||||
|
/// The measured height of the bounding box containing all printable
|
||||||
|
/// ASCII characters. This can be different from ascent - descent for
|
||||||
|
/// two reasons: non-letter symbols like @ and $ often exceed the
|
||||||
|
/// the ascender and descender lines; and fonts often bake the line
|
||||||
|
/// gap into the ascent and descent metrics (as per, e.g., the Google
|
||||||
|
/// Fonts guidelines: https://simoncozens.github.io/gf-docs/metrics.html).
|
||||||
|
///
|
||||||
|
/// Positive value in px
|
||||||
|
ascii_height: ?f64 = null,
|
||||||
|
|
||||||
/// The width of the character "水" (CJK water ideograph, U+6C34),
|
/// The width of the character "水" (CJK water ideograph, U+6C34),
|
||||||
/// if present. This is used for font size adjustment, to normalize
|
/// if present. This is used for font size adjustment, to normalize
|
||||||
/// the width of CJK fonts mixed with latin fonts.
|
/// the width of CJK fonts mixed with latin fonts.
|
||||||
|
|
@ -144,11 +166,20 @@ pub const FaceMetrics = struct {
|
||||||
return 0.75 * self.capHeight();
|
return 0.75 * self.capHeight();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Convenience function for getting the ASCII height. If we
|
||||||
|
/// couldn't measure this, we use 1.5 * cap_height as our
|
||||||
|
/// estimator, based on measurements across programming fonts.
|
||||||
|
pub inline fn asciiHeight(self: FaceMetrics) f64 {
|
||||||
|
if (self.ascii_height) |value| if (value > 0) return value;
|
||||||
|
return 1.5 * self.capHeight();
|
||||||
|
}
|
||||||
|
|
||||||
/// Convenience function for getting the ideograph width. If this is
|
/// Convenience function for getting the ideograph width. If this is
|
||||||
/// not defined in the font, we estimate it as two cell widths.
|
/// not defined in the font, we estimate it as the minimum of the
|
||||||
|
/// ascii height and two cell widths.
|
||||||
pub inline fn icWidth(self: FaceMetrics) f64 {
|
pub inline fn icWidth(self: FaceMetrics) f64 {
|
||||||
if (self.ic_width) |value| if (value > 0) return value;
|
if (self.ic_width) |value| if (value > 0) return value;
|
||||||
return 2 * self.cell_width;
|
return @min(self.asciiHeight(), 2 * self.cell_width);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convenience function for getting the underline thickness. If
|
/// Convenience function for getting the underline thickness. If
|
||||||
|
|
@ -195,8 +226,10 @@ pub fn calc(face: FaceMetrics) Metrics {
|
||||||
// We use the ceiling of the provided cell width and height to ensure
|
// We use the ceiling of the provided cell width and height to ensure
|
||||||
// that the cell is large enough for the provided size, since we cast
|
// that the cell is large enough for the provided size, since we cast
|
||||||
// it to an integer later.
|
// it to an integer later.
|
||||||
const cell_width = @ceil(face.cell_width);
|
const face_width = face.cell_width;
|
||||||
const cell_height = @ceil(face.lineHeight());
|
const face_height = face.lineHeight();
|
||||||
|
const cell_width = @ceil(face_width);
|
||||||
|
const cell_height = @ceil(face_height);
|
||||||
|
|
||||||
// We split our line gap in two parts, and put half of it on the top
|
// We split our line gap in two parts, and put half of it on the top
|
||||||
// of the cell and the other half on the bottom, so that our text never
|
// of the cell and the other half on the bottom, so that our text never
|
||||||
|
|
@ -205,7 +238,11 @@ pub fn calc(face: FaceMetrics) Metrics {
|
||||||
|
|
||||||
// Unlike all our other metrics, `cell_baseline` is relative to the
|
// Unlike all our other metrics, `cell_baseline` is relative to the
|
||||||
// BOTTOM of the cell.
|
// BOTTOM of the cell.
|
||||||
const cell_baseline = @round(half_line_gap - face.descent);
|
const face_baseline = half_line_gap - face.descent;
|
||||||
|
const cell_baseline = @round(face_baseline);
|
||||||
|
|
||||||
|
// We keep track of the vertical bearing of the face in the cell
|
||||||
|
const face_y = cell_baseline - face_baseline;
|
||||||
|
|
||||||
// We calculate a top_to_baseline to make following calculations simpler.
|
// We calculate a top_to_baseline to make following calculations simpler.
|
||||||
const top_to_baseline = cell_height - cell_baseline;
|
const top_to_baseline = cell_height - cell_baseline;
|
||||||
|
|
@ -218,16 +255,11 @@ pub fn calc(face: FaceMetrics) Metrics {
|
||||||
const underline_position = @round(top_to_baseline - face.underlinePosition());
|
const underline_position = @round(top_to_baseline - face.underlinePosition());
|
||||||
const strikethrough_position = @round(top_to_baseline - face.strikethroughPosition());
|
const strikethrough_position = @round(top_to_baseline - face.strikethroughPosition());
|
||||||
|
|
||||||
// The calculation for icon height in the nerd fonts patcher
|
// Same heuristic as the font_patcher script. We store icon_height
|
||||||
// is two thirds cap height to one third line height, but we
|
// separately from face_height such that modifiers can apply to the former
|
||||||
// use an opinionated default of 1.2 * cap height instead.
|
// without affecting the latter.
|
||||||
//
|
const icon_height = face_height;
|
||||||
// Doing this prevents fonts with very large line heights
|
const icon_height_single = (2 * cap_height + face_height) / 3;
|
||||||
// from having excessively oversized icons, and allows fonts
|
|
||||||
// with very small line heights to still have roomy icons.
|
|
||||||
//
|
|
||||||
// We do cap it at `cell_height` though for obvious reasons.
|
|
||||||
const icon_height = @min(cell_height, cap_height * 1.2);
|
|
||||||
|
|
||||||
var result: Metrics = .{
|
var result: Metrics = .{
|
||||||
.cell_width = @intFromFloat(cell_width),
|
.cell_width = @intFromFloat(cell_width),
|
||||||
|
|
@ -241,7 +273,11 @@ pub fn calc(face: FaceMetrics) Metrics {
|
||||||
.overline_thickness = @intFromFloat(underline_thickness),
|
.overline_thickness = @intFromFloat(underline_thickness),
|
||||||
.box_thickness = @intFromFloat(underline_thickness),
|
.box_thickness = @intFromFloat(underline_thickness),
|
||||||
.cursor_height = @intFromFloat(cell_height),
|
.cursor_height = @intFromFloat(cell_height),
|
||||||
.icon_height = @intFromFloat(icon_height),
|
.icon_height = icon_height,
|
||||||
|
.icon_height_single = icon_height_single,
|
||||||
|
.face_width = face_width,
|
||||||
|
.face_height = face_height,
|
||||||
|
.face_y = face_y,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Ensure all metrics are within their allowable range.
|
// Ensure all metrics are within their allowable range.
|
||||||
|
|
@ -267,11 +303,6 @@ pub fn apply(self: *Metrics, mods: ModifierSet) void {
|
||||||
const new = @max(entry.value_ptr.apply(original), 1);
|
const new = @max(entry.value_ptr.apply(original), 1);
|
||||||
if (new == original) continue;
|
if (new == original) continue;
|
||||||
|
|
||||||
// Preserve the original cell width if not set.
|
|
||||||
if (self.original_cell_width == null) {
|
|
||||||
self.original_cell_width = self.cell_width;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set the new value
|
// Set the new value
|
||||||
@field(self, @tagName(tag)) = new;
|
@field(self, @tagName(tag)) = new;
|
||||||
|
|
||||||
|
|
@ -288,6 +319,7 @@ pub fn apply(self: *Metrics, mods: ModifierSet) void {
|
||||||
const diff = new - original;
|
const diff = new - original;
|
||||||
const diff_bottom = diff / 2;
|
const diff_bottom = diff / 2;
|
||||||
const diff_top = diff - diff_bottom;
|
const diff_top = diff - diff_bottom;
|
||||||
|
self.face_y += @floatFromInt(diff_bottom);
|
||||||
self.cell_baseline +|= diff_bottom;
|
self.cell_baseline +|= diff_bottom;
|
||||||
self.underline_position +|= diff_top;
|
self.underline_position +|= diff_top;
|
||||||
self.strikethrough_position +|= diff_top;
|
self.strikethrough_position +|= diff_top;
|
||||||
|
|
@ -296,6 +328,7 @@ pub fn apply(self: *Metrics, mods: ModifierSet) void {
|
||||||
const diff = original - new;
|
const diff = original - new;
|
||||||
const diff_bottom = diff / 2;
|
const diff_bottom = diff / 2;
|
||||||
const diff_top = diff - diff_bottom;
|
const diff_top = diff - diff_bottom;
|
||||||
|
self.face_y -= @floatFromInt(diff_bottom);
|
||||||
self.cell_baseline -|= diff_bottom;
|
self.cell_baseline -|= diff_bottom;
|
||||||
self.underline_position -|= diff_top;
|
self.underline_position -|= diff_top;
|
||||||
self.strikethrough_position -|= diff_top;
|
self.strikethrough_position -|= diff_top;
|
||||||
|
|
@ -303,6 +336,10 @@ pub fn apply(self: *Metrics, mods: ModifierSet) void {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
inline .icon_height => {
|
||||||
|
self.icon_height = entry.value_ptr.apply(self.icon_height);
|
||||||
|
self.icon_height_single = entry.value_ptr.apply(self.icon_height_single);
|
||||||
|
},
|
||||||
|
|
||||||
inline else => |tag| {
|
inline else => |tag| {
|
||||||
@field(self, @tagName(tag)) = entry.value_ptr.apply(@field(self, @tagName(tag)));
|
@field(self, @tagName(tag)) = entry.value_ptr.apply(@field(self, @tagName(tag)));
|
||||||
|
|
@ -398,25 +435,35 @@ pub const Modifier = union(enum) {
|
||||||
/// Apply a modifier to a numeric value.
|
/// Apply a modifier to a numeric value.
|
||||||
pub fn apply(self: Modifier, v: anytype) @TypeOf(v) {
|
pub fn apply(self: Modifier, v: anytype) @TypeOf(v) {
|
||||||
const T = @TypeOf(v);
|
const T = @TypeOf(v);
|
||||||
const signed = @typeInfo(T).int.signedness == .signed;
|
const Tinfo = @typeInfo(T);
|
||||||
return switch (self) {
|
return switch (comptime Tinfo) {
|
||||||
.percent => |p| percent: {
|
.int, .comptime_int => switch (self) {
|
||||||
const p_clamped: f64 = @max(0, p);
|
.percent => |p| percent: {
|
||||||
const v_f64: f64 = @floatFromInt(v);
|
const p_clamped: f64 = @max(0, p);
|
||||||
const applied_f64: f64 = @round(v_f64 * p_clamped);
|
const v_f64: f64 = @floatFromInt(v);
|
||||||
const applied_T: T = @intFromFloat(applied_f64);
|
const applied_f64: f64 = @round(v_f64 * p_clamped);
|
||||||
break :percent applied_T;
|
const applied_T: T = @intFromFloat(applied_f64);
|
||||||
},
|
break :percent applied_T;
|
||||||
|
},
|
||||||
|
|
||||||
.absolute => |abs| absolute: {
|
.absolute => |abs| absolute: {
|
||||||
const v_i64: i64 = @intCast(v);
|
const v_i64: i64 = @intCast(v);
|
||||||
const abs_i64: i64 = @intCast(abs);
|
const abs_i64: i64 = @intCast(abs);
|
||||||
const applied_i64: i64 = v_i64 +| abs_i64;
|
const applied_i64: i64 = v_i64 +| abs_i64;
|
||||||
const clamped_i64: i64 = if (signed) applied_i64 else @max(0, applied_i64);
|
const clamped_i64: i64 = if (Tinfo.int.signedness == .signed)
|
||||||
const applied_T: T = std.math.cast(T, clamped_i64) orelse
|
applied_i64
|
||||||
std.math.maxInt(T) * @as(T, @intCast(std.math.sign(clamped_i64)));
|
else
|
||||||
break :absolute applied_T;
|
@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 +509,7 @@ pub const Key = key: {
|
||||||
var enumFields: [field_infos.len]std.builtin.Type.EnumField = undefined;
|
var enumFields: [field_infos.len]std.builtin.Type.EnumField = undefined;
|
||||||
var count: usize = 0;
|
var count: usize = 0;
|
||||||
for (field_infos, 0..) |field, i| {
|
for (field_infos, 0..) |field, i| {
|
||||||
if (field.type != u32 and field.type != i32) continue;
|
if (field.type != u32 and field.type != i32 and field.type != f64) continue;
|
||||||
enumFields[i] = .{ .name = field.name, .value = i };
|
enumFields[i] = .{ .name = field.name, .value = i };
|
||||||
count += 1;
|
count += 1;
|
||||||
}
|
}
|
||||||
|
|
@ -493,7 +540,11 @@ fn init() Metrics {
|
||||||
.overline_thickness = 0,
|
.overline_thickness = 0,
|
||||||
.box_thickness = 0,
|
.box_thickness = 0,
|
||||||
.cursor_height = 0,
|
.cursor_height = 0,
|
||||||
.icon_height = 0,
|
.icon_height = 0.0,
|
||||||
|
.icon_height_single = 0.0,
|
||||||
|
.face_width = 0.0,
|
||||||
|
.face_height = 0.0,
|
||||||
|
.face_y = 0.0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -523,6 +574,7 @@ test "Metrics: adjust cell height smaller" {
|
||||||
try set.put(alloc, .cell_height, .{ .percent = 0.75 });
|
try set.put(alloc, .cell_height, .{ .percent = 0.75 });
|
||||||
|
|
||||||
var m: Metrics = init();
|
var m: Metrics = init();
|
||||||
|
m.face_y = 0.33;
|
||||||
m.cell_baseline = 50;
|
m.cell_baseline = 50;
|
||||||
m.underline_position = 55;
|
m.underline_position = 55;
|
||||||
m.strikethrough_position = 30;
|
m.strikethrough_position = 30;
|
||||||
|
|
@ -530,6 +582,7 @@ test "Metrics: adjust cell height smaller" {
|
||||||
m.cell_height = 100;
|
m.cell_height = 100;
|
||||||
m.cursor_height = 100;
|
m.cursor_height = 100;
|
||||||
m.apply(set);
|
m.apply(set);
|
||||||
|
try testing.expectEqual(-11.67, m.face_y);
|
||||||
try testing.expectEqual(@as(u32, 75), m.cell_height);
|
try testing.expectEqual(@as(u32, 75), m.cell_height);
|
||||||
try testing.expectEqual(@as(u32, 38), m.cell_baseline);
|
try testing.expectEqual(@as(u32, 38), m.cell_baseline);
|
||||||
try testing.expectEqual(@as(u32, 42), m.underline_position);
|
try testing.expectEqual(@as(u32, 42), m.underline_position);
|
||||||
|
|
@ -551,6 +604,7 @@ test "Metrics: adjust cell height larger" {
|
||||||
try set.put(alloc, .cell_height, .{ .percent = 1.75 });
|
try set.put(alloc, .cell_height, .{ .percent = 1.75 });
|
||||||
|
|
||||||
var m: Metrics = init();
|
var m: Metrics = init();
|
||||||
|
m.face_y = 0.33;
|
||||||
m.cell_baseline = 50;
|
m.cell_baseline = 50;
|
||||||
m.underline_position = 55;
|
m.underline_position = 55;
|
||||||
m.strikethrough_position = 30;
|
m.strikethrough_position = 30;
|
||||||
|
|
@ -558,6 +612,7 @@ test "Metrics: adjust cell height larger" {
|
||||||
m.cell_height = 100;
|
m.cell_height = 100;
|
||||||
m.cursor_height = 100;
|
m.cursor_height = 100;
|
||||||
m.apply(set);
|
m.apply(set);
|
||||||
|
try testing.expectEqual(37.33, m.face_y);
|
||||||
try testing.expectEqual(@as(u32, 175), m.cell_height);
|
try testing.expectEqual(@as(u32, 175), m.cell_height);
|
||||||
try testing.expectEqual(@as(u32, 87), m.cell_baseline);
|
try testing.expectEqual(@as(u32, 87), m.cell_baseline);
|
||||||
try testing.expectEqual(@as(u32, 93), m.underline_position);
|
try testing.expectEqual(@as(u32, 93), m.underline_position);
|
||||||
|
|
@ -567,6 +622,48 @@ test "Metrics: adjust cell height larger" {
|
||||||
try testing.expectEqual(@as(u32, 100), m.cursor_height);
|
try testing.expectEqual(@as(u32, 100), m.cursor_height);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test "Metrics: adjust icon height by percentage" {
|
||||||
|
const testing = std.testing;
|
||||||
|
const alloc = testing.allocator;
|
||||||
|
|
||||||
|
var set: ModifierSet = .{};
|
||||||
|
defer set.deinit(alloc);
|
||||||
|
try set.put(alloc, .icon_height, .{ .percent = 0.75 });
|
||||||
|
|
||||||
|
var m: Metrics = init();
|
||||||
|
m.icon_height = 100.0;
|
||||||
|
m.icon_height_single = 80.0;
|
||||||
|
m.face_height = 100.0;
|
||||||
|
m.face_y = 1.0;
|
||||||
|
m.apply(set);
|
||||||
|
try testing.expectEqual(75.0, m.icon_height);
|
||||||
|
try testing.expectEqual(60.0, m.icon_height_single);
|
||||||
|
// Face metrics not affected
|
||||||
|
try testing.expectEqual(100.0, m.face_height);
|
||||||
|
try testing.expectEqual(1.0, m.face_y);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "Metrics: adjust icon height by absolute pixels" {
|
||||||
|
const testing = std.testing;
|
||||||
|
const alloc = testing.allocator;
|
||||||
|
|
||||||
|
var set: ModifierSet = .{};
|
||||||
|
defer set.deinit(alloc);
|
||||||
|
try set.put(alloc, .icon_height, .{ .absolute = -5 });
|
||||||
|
|
||||||
|
var m: Metrics = init();
|
||||||
|
m.icon_height = 100.0;
|
||||||
|
m.icon_height_single = 80.0;
|
||||||
|
m.face_height = 100.0;
|
||||||
|
m.face_y = 1.0;
|
||||||
|
m.apply(set);
|
||||||
|
try testing.expectEqual(95.0, m.icon_height);
|
||||||
|
try testing.expectEqual(75.0, m.icon_height_single);
|
||||||
|
// Face metrics not affected
|
||||||
|
try testing.expectEqual(100.0, m.face_height);
|
||||||
|
try testing.expectEqual(1.0, m.face_y);
|
||||||
|
}
|
||||||
|
|
||||||
test "Modifier: parse absolute" {
|
test "Modifier: parse absolute" {
|
||||||
const testing = std.testing;
|
const testing = std.testing;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -270,11 +270,9 @@ pub fn renderGlyph(
|
||||||
// Always use these constraints for emoji.
|
// Always use these constraints for emoji.
|
||||||
if (p == .emoji) {
|
if (p == .emoji) {
|
||||||
render_opts.constraint = .{
|
render_opts.constraint = .{
|
||||||
// Make the emoji as wide as possible, scaling proportionally,
|
// Scale emoji to be as large as possible
|
||||||
// but then scale it down as necessary if its new size exceeds
|
// while preserving their aspect ratio.
|
||||||
// the cell height.
|
.size = .cover,
|
||||||
.size_horizontal = .cover,
|
|
||||||
.size_vertical = .fit,
|
|
||||||
|
|
||||||
// Center the emoji in its cells.
|
// Center the emoji in its cells.
|
||||||
.align_horizontal = .center,
|
.align_horizontal = .center,
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,14 @@ pub const Variation = struct {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// The size and position of a glyph.
|
||||||
|
pub const GlyphSize = struct {
|
||||||
|
width: f64,
|
||||||
|
height: f64,
|
||||||
|
x: f64,
|
||||||
|
y: f64,
|
||||||
|
};
|
||||||
|
|
||||||
/// Additional options for rendering glyphs.
|
/// Additional options for rendering glyphs.
|
||||||
pub const RenderOptions = struct {
|
pub const RenderOptions = struct {
|
||||||
/// The metrics that are defining the grid layout. These are usually
|
/// The metrics that are defining the grid layout. These are usually
|
||||||
|
|
@ -136,10 +144,8 @@ pub const RenderOptions = struct {
|
||||||
/// Don't constrain the glyph in any way.
|
/// Don't constrain the glyph in any way.
|
||||||
pub const none: Constraint = .{};
|
pub const none: Constraint = .{};
|
||||||
|
|
||||||
/// Vertical sizing rule.
|
/// Sizing rule.
|
||||||
size_vertical: Size = .none,
|
size: Size = .none,
|
||||||
/// Horizontal sizing rule.
|
|
||||||
size_horizontal: Size = .none,
|
|
||||||
|
|
||||||
/// Vertical alignment rule.
|
/// Vertical alignment rule.
|
||||||
align_vertical: Align = .none,
|
align_vertical: Align = .none,
|
||||||
|
|
@ -155,42 +161,40 @@ pub const RenderOptions = struct {
|
||||||
/// Bottom padding when resizing.
|
/// Bottom padding when resizing.
|
||||||
pad_bottom: f64 = 0.0,
|
pad_bottom: f64 = 0.0,
|
||||||
|
|
||||||
// This acts as a multiple of the provided width when applying
|
// Size and bearings of the glyph relative
|
||||||
// constraints, so if this is 1.6 for example, then a width of
|
// to the bounding box of its scale group.
|
||||||
// 10 would be treated as though it were 16.
|
relative_width: f64 = 1.0,
|
||||||
group_width: f64 = 1.0,
|
relative_height: f64 = 1.0,
|
||||||
// This acts as a multiple of the provided height when applying
|
relative_x: f64 = 0.0,
|
||||||
// constraints, so if this is 1.6 for example, then a height of
|
relative_y: f64 = 0.0,
|
||||||
// 10 would be treated as though it were 16.
|
|
||||||
group_height: f64 = 1.0,
|
|
||||||
// This is an x offset for the actual width within the group width.
|
|
||||||
// If this is 0.5 then the glyph will be offset so that its left
|
|
||||||
// edge sits at the halfway point of the group width.
|
|
||||||
group_x: f64 = 0.0,
|
|
||||||
// This is a y offset for the actual height within the group height.
|
|
||||||
// If this is 0.5 then the glyph will be offset so that its bottom
|
|
||||||
// edge sits at the halfway point of the group height.
|
|
||||||
group_y: f64 = 0.0,
|
|
||||||
|
|
||||||
/// Maximum ratio of width to height when resizing.
|
/// Maximum aspect ratio (width/height) to allow when stretching.
|
||||||
max_xy_ratio: ?f64 = null,
|
max_xy_ratio: ?f64 = null,
|
||||||
|
|
||||||
/// Maximum number of cells horizontally to use.
|
/// Maximum number of cells horizontally to use.
|
||||||
max_constraint_width: u2 = 2,
|
max_constraint_width: u2 = 2,
|
||||||
|
|
||||||
/// What to use as the height metric when constraining the glyph.
|
/// What to use as the height metric when constraining the glyph and
|
||||||
|
/// the constraint width is 1,
|
||||||
height: Height = .cell,
|
height: Height = .cell,
|
||||||
|
|
||||||
pub const Size = enum {
|
pub const Size = enum {
|
||||||
/// Don't change the size of this glyph.
|
/// Don't change the size of this glyph.
|
||||||
none,
|
none,
|
||||||
/// Move the glyph and optionally scale it down
|
/// Scale the glyph down if needed to fit within the bounds,
|
||||||
/// proportionally to fit within the given axis.
|
/// preserving aspect ratio.
|
||||||
fit,
|
fit,
|
||||||
/// Move and resize the glyph proportionally to
|
/// Scale the glyph up or down to exactly match the bounds,
|
||||||
/// cover the given axis.
|
/// preserving aspect ratio.
|
||||||
cover,
|
cover,
|
||||||
/// Same as `cover` but not proportional.
|
/// Scale the glyph down if needed to fit within the bounds,
|
||||||
|
/// preserving aspect ratio. If the glyph doesn't cover a
|
||||||
|
/// single cell, scale up. If the glyph exceeds a single
|
||||||
|
/// cell but is within the bounds, do nothing.
|
||||||
|
/// (Nerd Font specific rule.)
|
||||||
|
fit_cover1,
|
||||||
|
/// Stretch the glyph to exactly fit the bounds in both
|
||||||
|
/// directions, disregarding aspect ratio.
|
||||||
stretch,
|
stretch,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -205,30 +209,29 @@ pub const RenderOptions = struct {
|
||||||
end,
|
end,
|
||||||
/// Move the glyph so that it is centered on this axis.
|
/// Move the glyph so that it is centered on this axis.
|
||||||
center,
|
center,
|
||||||
|
/// Move the glyph so that it is centered on this axis,
|
||||||
|
/// but always with respect to the first cell even for
|
||||||
|
/// multi-cell constraints. (Nerd Font specific rule.)
|
||||||
|
center1,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const Height = enum {
|
pub const Height = enum {
|
||||||
/// Use the full height of the cell for constraining this glyph.
|
/// Use the full line height of the primary face for
|
||||||
|
/// constraining this glyph.
|
||||||
cell,
|
cell,
|
||||||
/// Use the "icon height" from the grid metrics as the height.
|
/// Use the icon height from the grid metrics for
|
||||||
|
/// constraining this glyph. Unlike `cell`, the value of
|
||||||
|
/// this height depends on both the constraint width and the
|
||||||
|
/// affected by the `adjust-icon-height` config option.
|
||||||
icon,
|
icon,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// The size and position of a glyph.
|
|
||||||
pub const GlyphSize = struct {
|
|
||||||
width: f64,
|
|
||||||
height: f64,
|
|
||||||
x: f64,
|
|
||||||
y: f64,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Returns true if the constraint does anything. If it doesn't,
|
/// Returns true if the constraint does anything. If it doesn't,
|
||||||
/// because it neither sizes nor positions the glyph, then this
|
/// because it neither sizes nor positions the glyph, then this
|
||||||
/// returns false.
|
/// returns false.
|
||||||
pub inline fn doesAnything(self: Constraint) bool {
|
pub inline fn doesAnything(self: Constraint) bool {
|
||||||
return self.size_horizontal != .none or
|
return self.size != .none or
|
||||||
self.align_horizontal != .none or
|
self.align_horizontal != .none or
|
||||||
self.size_vertical != .none or
|
|
||||||
self.align_vertical != .none;
|
self.align_vertical != .none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -241,156 +244,252 @@ pub const RenderOptions = struct {
|
||||||
/// Number of cells horizontally available for this glyph.
|
/// Number of cells horizontally available for this glyph.
|
||||||
constraint_width: u2,
|
constraint_width: u2,
|
||||||
) GlyphSize {
|
) GlyphSize {
|
||||||
var g = glyph;
|
if (!self.doesAnything()) return glyph;
|
||||||
|
|
||||||
const available_width: f64 = @floatFromInt(
|
switch (self.size) {
|
||||||
metrics.cell_width * @min(
|
|
||||||
self.max_constraint_width,
|
|
||||||
constraint_width,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const available_height: f64 = @floatFromInt(switch (self.height) {
|
|
||||||
.cell => metrics.cell_height,
|
|
||||||
.icon => metrics.icon_height,
|
|
||||||
});
|
|
||||||
|
|
||||||
const w = available_width -
|
|
||||||
self.pad_left * available_width -
|
|
||||||
self.pad_right * available_width;
|
|
||||||
const h = available_height -
|
|
||||||
self.pad_top * available_height -
|
|
||||||
self.pad_bottom * available_height;
|
|
||||||
|
|
||||||
// Subtract padding from the bearings so that our
|
|
||||||
// alignment and sizing code works correctly. We
|
|
||||||
// re-add before returning.
|
|
||||||
g.x -= self.pad_left * available_width;
|
|
||||||
g.y -= self.pad_bottom * available_height;
|
|
||||||
|
|
||||||
// Multiply by group width and height for better sizing.
|
|
||||||
g.width *= self.group_width;
|
|
||||||
g.height *= self.group_height;
|
|
||||||
|
|
||||||
switch (self.size_horizontal) {
|
|
||||||
.none => {},
|
|
||||||
.fit => if (g.width > w) {
|
|
||||||
const orig_height = g.height;
|
|
||||||
// Adjust our height and width to proportionally
|
|
||||||
// scale them to fit the glyph to the cell width.
|
|
||||||
g.height *= w / g.width;
|
|
||||||
g.width = w;
|
|
||||||
// Set our x to 0 since anything else would mean
|
|
||||||
// the glyph extends outside of the cell width.
|
|
||||||
g.x = 0;
|
|
||||||
// Compensate our y to keep things vertically
|
|
||||||
// centered as they're scaled down.
|
|
||||||
g.y += (orig_height - g.height) / 2;
|
|
||||||
} else if (g.width + g.x > w) {
|
|
||||||
// If the width of the glyph can fit in the cell but
|
|
||||||
// is currently outside due to the left bearing, then
|
|
||||||
// we reduce the left bearing just enough to fit it
|
|
||||||
// back in the cell.
|
|
||||||
g.x = w - g.width;
|
|
||||||
} else if (g.x < 0) {
|
|
||||||
g.x = 0;
|
|
||||||
},
|
|
||||||
.cover => {
|
|
||||||
const orig_height = g.height;
|
|
||||||
|
|
||||||
g.height *= w / g.width;
|
|
||||||
g.width = w;
|
|
||||||
|
|
||||||
g.x = 0;
|
|
||||||
|
|
||||||
g.y += (orig_height - g.height) / 2;
|
|
||||||
},
|
|
||||||
.stretch => {
|
.stretch => {
|
||||||
g.width = w;
|
// Stretched glyphs are usually meant to align across cell
|
||||||
g.x = 0;
|
// boundaries, which works best if they're scaled and
|
||||||
|
// aligned to the grid rather than the face. This is most
|
||||||
|
// easily done by inserting this little fib in the metrics.
|
||||||
|
var m = metrics;
|
||||||
|
m.face_width = @floatFromInt(m.cell_width);
|
||||||
|
m.face_height = @floatFromInt(m.cell_height);
|
||||||
|
m.face_y = 0.0;
|
||||||
|
|
||||||
|
// Negative padding for stretched glyphs is a band-aid to
|
||||||
|
// avoid gaps due to pixel rounding, but at the cost of
|
||||||
|
// unsightly overlap artifacts. Since we scale and align to
|
||||||
|
// the grid rather than the face, we don't need it.
|
||||||
|
var c = self;
|
||||||
|
c.pad_bottom = @max(0, c.pad_bottom);
|
||||||
|
c.pad_top = @max(0, c.pad_top);
|
||||||
|
c.pad_left = @max(0, c.pad_left);
|
||||||
|
c.pad_right = @max(0, c.pad_right);
|
||||||
|
|
||||||
|
return c.constrainInner(glyph, m, constraint_width);
|
||||||
},
|
},
|
||||||
|
else => return self.constrainInner(glyph, metrics, constraint_width),
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
switch (self.size_vertical) {
|
fn constrainInner(
|
||||||
.none => {},
|
self: Constraint,
|
||||||
.fit => if (g.height > h) {
|
glyph: GlyphSize,
|
||||||
const orig_width = g.width;
|
metrics: Metrics,
|
||||||
// Adjust our height and width to proportionally
|
constraint_width: u2,
|
||||||
// scale them to fit the glyph to the cell height.
|
) GlyphSize {
|
||||||
g.width *= h / g.height;
|
// For extra wide font faces, never stretch glyphs across two cells.
|
||||||
g.height = h;
|
// This mirrors font_patcher.
|
||||||
// Set our y to 0 since anything else would mean
|
const min_constraint_width: u2 = if ((self.size == .stretch) and (metrics.face_width > 0.9 * metrics.face_height))
|
||||||
// the glyph extends outside of the cell height.
|
1
|
||||||
g.y = 0;
|
else
|
||||||
// Compensate our x to keep things horizontally
|
@min(self.max_constraint_width, constraint_width);
|
||||||
// centered as they're scaled down.
|
|
||||||
g.x += (orig_width - g.width) / 2;
|
|
||||||
} else if (g.height + g.y > h) {
|
|
||||||
// If the height of the glyph can fit in the cell but
|
|
||||||
// is currently outside due to the bottom bearing, then
|
|
||||||
// we reduce the bottom bearing just enough to fit it
|
|
||||||
// back in the cell.
|
|
||||||
g.y = h - g.height;
|
|
||||||
} else if (g.y < 0) {
|
|
||||||
g.y = 0;
|
|
||||||
},
|
|
||||||
.cover => {
|
|
||||||
const orig_width = g.width;
|
|
||||||
|
|
||||||
g.width *= h / g.height;
|
// The bounding box for the glyph's scale group.
|
||||||
g.height = h;
|
// Scaling and alignment rules are calculated for
|
||||||
|
// this box and then applied to the glyph.
|
||||||
g.y = 0;
|
var group: GlyphSize = group: {
|
||||||
|
const group_width = glyph.width / self.relative_width;
|
||||||
g.x += (orig_width - g.width) / 2;
|
const group_height = glyph.height / self.relative_height;
|
||||||
},
|
break :group .{
|
||||||
.stretch => {
|
.width = group_width,
|
||||||
g.height = h;
|
.height = group_height,
|
||||||
g.y = 0;
|
.x = glyph.x - (group_width * self.relative_x),
|
||||||
},
|
.y = glyph.y - (group_height * self.relative_y),
|
||||||
}
|
};
|
||||||
|
|
||||||
// Add group-relative position
|
|
||||||
g.x += self.group_x * g.width;
|
|
||||||
g.y += self.group_y * g.height;
|
|
||||||
|
|
||||||
// Divide group width and height back out before we align.
|
|
||||||
g.width /= self.group_width;
|
|
||||||
g.height /= self.group_height;
|
|
||||||
|
|
||||||
if (self.max_xy_ratio) |ratio| if (g.width > g.height * ratio) {
|
|
||||||
const orig_width = g.width;
|
|
||||||
g.width = g.height * ratio;
|
|
||||||
g.x += (orig_width - g.width) / 2;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
switch (self.align_horizontal) {
|
// Apply prescribed scaling, preserving the
|
||||||
.none => {},
|
// center bearings of the group bounding box
|
||||||
.start => g.x = 0,
|
const width_factor, const height_factor = self.scale_factors(group, metrics, min_constraint_width);
|
||||||
.end => g.x = w - g.width,
|
const center_x = group.x + (group.width / 2);
|
||||||
.center => g.x = (w - g.width) / 2,
|
const center_y = group.y + (group.height / 2);
|
||||||
|
group.width *= width_factor;
|
||||||
|
group.height *= height_factor;
|
||||||
|
group.x = center_x - (group.width / 2);
|
||||||
|
group.y = center_y - (group.height / 2);
|
||||||
|
|
||||||
|
// NOTE: font_patcher jumps through a lot of hoops at this
|
||||||
|
// point to ensure that the glyph remains within the target
|
||||||
|
// bounding box after rounding to font definition units.
|
||||||
|
// This is irrelevant here as we're not rounding, we're
|
||||||
|
// staying in f64 and heading straight to rendering.
|
||||||
|
|
||||||
|
// Apply prescribed alignment
|
||||||
|
group.y = self.aligned_y(group, metrics);
|
||||||
|
group.x = self.aligned_x(group, metrics, min_constraint_width);
|
||||||
|
|
||||||
|
// Transfer the scaling and alignment back to the glyph and return.
|
||||||
|
return .{
|
||||||
|
.width = width_factor * glyph.width,
|
||||||
|
.height = height_factor * glyph.height,
|
||||||
|
.x = group.x + (group.width * self.relative_x),
|
||||||
|
.y = group.y + (group.height * self.relative_y),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return width and height scaling factors for this scaling group.
|
||||||
|
fn scale_factors(
|
||||||
|
self: Constraint,
|
||||||
|
group: GlyphSize,
|
||||||
|
metrics: Metrics,
|
||||||
|
min_constraint_width: u2,
|
||||||
|
) struct { f64, f64 } {
|
||||||
|
if (self.size == .none) {
|
||||||
|
return .{ 1.0, 1.0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (self.align_vertical) {
|
const multi_cell = (min_constraint_width > 1);
|
||||||
.none => {},
|
|
||||||
.start => g.y = 0,
|
const pad_width_factor = @as(f64, @floatFromInt(min_constraint_width)) - (self.pad_left + self.pad_right);
|
||||||
.end => g.y = h - g.height,
|
const pad_height_factor = 1 - (self.pad_bottom + self.pad_top);
|
||||||
.center => g.y = (h - g.height) / 2,
|
|
||||||
|
const target_width = pad_width_factor * metrics.face_width;
|
||||||
|
const target_height = pad_height_factor * switch (self.height) {
|
||||||
|
.cell => metrics.face_height,
|
||||||
|
// Like font-patcher, the icon constraint height depends on the
|
||||||
|
// constraint width. Unlike font-patcher, the multi-cell
|
||||||
|
// icon_height may be different from face_height due to the
|
||||||
|
// `adjust-icon-height` config option.
|
||||||
|
.icon => if (multi_cell)
|
||||||
|
metrics.icon_height
|
||||||
|
else
|
||||||
|
metrics.icon_height_single,
|
||||||
|
};
|
||||||
|
|
||||||
|
var width_factor = target_width / group.width;
|
||||||
|
var height_factor = target_height / group.height;
|
||||||
|
|
||||||
|
switch (self.size) {
|
||||||
|
.none => unreachable,
|
||||||
|
.fit => {
|
||||||
|
// Scale down to fit if needed
|
||||||
|
height_factor = @min(1, width_factor, height_factor);
|
||||||
|
width_factor = height_factor;
|
||||||
|
},
|
||||||
|
.cover => {
|
||||||
|
// Scale to cover
|
||||||
|
height_factor = @min(width_factor, height_factor);
|
||||||
|
width_factor = height_factor;
|
||||||
|
},
|
||||||
|
.fit_cover1 => {
|
||||||
|
// Scale down to fit or up to cover at least one cell
|
||||||
|
// NOTE: This is similar to font_patcher's "pa" mode,
|
||||||
|
// however, font_patcher will only do the upscaling
|
||||||
|
// part if the constraint width is 1, resulting in
|
||||||
|
// some icons becoming smaller when the constraint
|
||||||
|
// width increases. You'd see icons shrinking when
|
||||||
|
// opening up a space after them. This makes no
|
||||||
|
// sense, so we've fixed the rule such that these
|
||||||
|
// icons are scaled to the same size for multi-cell
|
||||||
|
// constraints as they would be for single-cell.
|
||||||
|
height_factor = @min(width_factor, height_factor);
|
||||||
|
if (multi_cell and (height_factor > 1)) {
|
||||||
|
// Call back into this function with
|
||||||
|
// constraint width 1 to get single-cell scale
|
||||||
|
// factors. We use the height factor as width
|
||||||
|
// could have been modified by max_xy_ratio.
|
||||||
|
_, const single_height_factor = self.scale_factors(group, metrics, 1);
|
||||||
|
height_factor = @max(1, single_height_factor);
|
||||||
|
}
|
||||||
|
width_factor = height_factor;
|
||||||
|
},
|
||||||
|
.stretch => {},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-add our padding before returning.
|
// Reduce aspect ratio if required
|
||||||
g.x += self.pad_left * available_width;
|
if (self.max_xy_ratio) |ratio| {
|
||||||
g.y += self.pad_bottom * available_height;
|
if (group.width * width_factor > group.height * height_factor * ratio) {
|
||||||
|
width_factor = group.height * height_factor * ratio / group.width;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// If the available height is less than the cell height, we
|
return .{ width_factor, height_factor };
|
||||||
// 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 g;
|
/// Return vertical bearing for aligning this group
|
||||||
|
fn aligned_y(
|
||||||
|
self: Constraint,
|
||||||
|
group: GlyphSize,
|
||||||
|
metrics: Metrics,
|
||||||
|
) f64 {
|
||||||
|
if ((self.size == .none) and (self.align_vertical == .none)) {
|
||||||
|
// If we don't have any constraints affecting the vertical axis,
|
||||||
|
// we don't touch vertical alignment.
|
||||||
|
return group.y;
|
||||||
|
}
|
||||||
|
// We use face_height and offset by face_y, rather than
|
||||||
|
// using cell_height directly, to account for the asymmetry
|
||||||
|
// of the pixel cell around the face (a consequence of
|
||||||
|
// aligning the baseline with a pixel boundary rather than
|
||||||
|
// vertically centering the face).
|
||||||
|
const pad_bottom_dy = self.pad_bottom * metrics.face_height;
|
||||||
|
const pad_top_dy = self.pad_top * metrics.face_height;
|
||||||
|
const start_y = metrics.face_y + pad_bottom_dy;
|
||||||
|
const end_y = metrics.face_y + (metrics.face_height - group.height - pad_top_dy);
|
||||||
|
const center_y = (start_y + end_y) / 2;
|
||||||
|
return switch (self.align_vertical) {
|
||||||
|
// NOTE: Even if there is no prescribed alignment, we ensure
|
||||||
|
// that the group doesn't protrude outside the padded cell,
|
||||||
|
// since this is implied by every available size constraint. If
|
||||||
|
// the group is too high we fall back to centering, though if we
|
||||||
|
// hit the .none prong we always have self.size != .none, so
|
||||||
|
// this should never happen.
|
||||||
|
.none => if (end_y < start_y)
|
||||||
|
center_y
|
||||||
|
else
|
||||||
|
@max(start_y, @min(group.y, end_y)),
|
||||||
|
.start => start_y,
|
||||||
|
.end => end_y,
|
||||||
|
.center, .center1 => center_y,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return horizontal bearing for aligning this group
|
||||||
|
fn aligned_x(
|
||||||
|
self: Constraint,
|
||||||
|
group: GlyphSize,
|
||||||
|
metrics: Metrics,
|
||||||
|
min_constraint_width: u2,
|
||||||
|
) f64 {
|
||||||
|
if ((self.size == .none) and (self.align_horizontal == .none)) {
|
||||||
|
// If we don't have any constraints affecting the horizontal
|
||||||
|
// axis, we don't touch horizontal alignment.
|
||||||
|
return group.x;
|
||||||
|
}
|
||||||
|
// For multi-cell constraints, we align relative to the span
|
||||||
|
// from the left edge of the first cell to the right edge of
|
||||||
|
// the last face cell assuming it's left-aligned within the
|
||||||
|
// rounded and adjusted pixel cell. Any horizontal offset to
|
||||||
|
// center the face within the grid cell is the responsibility
|
||||||
|
// of the backend-specific rendering code, and should be done
|
||||||
|
// after applying constraints.
|
||||||
|
const full_face_span = metrics.face_width + @as(f64, @floatFromInt((min_constraint_width - 1) * metrics.cell_width));
|
||||||
|
const pad_left_dx = self.pad_left * metrics.face_width;
|
||||||
|
const pad_right_dx = self.pad_right * metrics.face_width;
|
||||||
|
const start_x = pad_left_dx;
|
||||||
|
const end_x = full_face_span - group.width - pad_right_dx;
|
||||||
|
return switch (self.align_horizontal) {
|
||||||
|
// NOTE: Even if there is no prescribed alignment, we ensure
|
||||||
|
// that the glyph doesn't protrude outside the padded cell,
|
||||||
|
// since this is implied by every available size constraint. The
|
||||||
|
// left-side bound has priority if the group is too wide, though
|
||||||
|
// if we hit the .none prong we always have self.size != .none,
|
||||||
|
// so this should never happen.
|
||||||
|
.none => @max(start_x, @min(group.x, end_x)),
|
||||||
|
.start => start_x,
|
||||||
|
.end => @max(start_x, end_x),
|
||||||
|
.center => @max(start_x, (start_x + end_x) / 2),
|
||||||
|
// NOTE: .center1 implements the font_patcher rule of centering
|
||||||
|
// in the first cell even for multi-cell constraints. Since glyphs
|
||||||
|
// are not allowed to protrude to the left, this results in the
|
||||||
|
// left-alignment like .start when the glyph is wider than a cell.
|
||||||
|
.center1 => center1: {
|
||||||
|
const end1_x = metrics.face_width - group.width - pad_right_dx;
|
||||||
|
break :center1 @max(start_x, (start_x + end1_x) / 2);
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
@ -412,3 +511,197 @@ test "Variation.Id: slnt should be 1936486004" {
|
||||||
try testing.expectEqual(@as(u32, 1936486004), @as(u32, @bitCast(id)));
|
try testing.expectEqual(@as(u32, 1936486004), @as(u32, @bitCast(id)));
|
||||||
try testing.expectEqualStrings("slnt", &(id.str()));
|
try testing.expectEqualStrings("slnt", &(id.str()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test "Constraints" {
|
||||||
|
const comparison = @import("../datastruct/comparison.zig");
|
||||||
|
const getConstraint = @import("nerd_font_attributes.zig").getConstraint;
|
||||||
|
|
||||||
|
// Hardcoded data matches metrics from CoreText at size 12 and DPI 96.
|
||||||
|
|
||||||
|
// Define grid metrics (matches font-family = JetBrains Mono)
|
||||||
|
const metrics: Metrics = .{
|
||||||
|
.cell_width = 10,
|
||||||
|
.cell_height = 22,
|
||||||
|
.cell_baseline = 5,
|
||||||
|
.underline_position = 19,
|
||||||
|
.underline_thickness = 1,
|
||||||
|
.strikethrough_position = 12,
|
||||||
|
.strikethrough_thickness = 1,
|
||||||
|
.overline_position = 0,
|
||||||
|
.overline_thickness = 1,
|
||||||
|
.box_thickness = 1,
|
||||||
|
.cursor_thickness = 1,
|
||||||
|
.cursor_height = 22,
|
||||||
|
.icon_height = 21.12,
|
||||||
|
.icon_height_single = 44.48 / 3.0,
|
||||||
|
.face_width = 9.6,
|
||||||
|
.face_height = 21.12,
|
||||||
|
.face_y = 0.2,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ASCII (no constraint).
|
||||||
|
{
|
||||||
|
const constraint: RenderOptions.Constraint = .none;
|
||||||
|
|
||||||
|
// BBox of 'x' from JetBrains Mono.
|
||||||
|
const glyph_x: GlyphSize = .{
|
||||||
|
.width = 6.784,
|
||||||
|
.height = 15.28,
|
||||||
|
.x = 1.408,
|
||||||
|
.y = 4.84,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Any constraint width: do nothing.
|
||||||
|
inline for (.{ 1, 2 }) |constraint_width| {
|
||||||
|
try comparison.expectApproxEqual(
|
||||||
|
glyph_x,
|
||||||
|
constraint.constrain(glyph_x, metrics, constraint_width),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Symbol (same constraint as hardcoded in Renderer.addGlyph).
|
||||||
|
{
|
||||||
|
const constraint: RenderOptions.Constraint = .{ .size = .fit };
|
||||||
|
|
||||||
|
// BBox of '■' (0x25A0 black square) from Iosevka.
|
||||||
|
// NOTE: This glyph is designed to span two cells.
|
||||||
|
const glyph_25A0: GlyphSize = .{
|
||||||
|
.width = 10.272,
|
||||||
|
.height = 10.272,
|
||||||
|
.x = 2.864,
|
||||||
|
.y = 5.304,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Constraint width 1: scale down and shift to fit a single cell.
|
||||||
|
try comparison.expectApproxEqual(
|
||||||
|
GlyphSize{
|
||||||
|
.width = metrics.face_width,
|
||||||
|
.height = metrics.face_width,
|
||||||
|
.x = 0,
|
||||||
|
.y = 5.64,
|
||||||
|
},
|
||||||
|
constraint.constrain(glyph_25A0, metrics, 1),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Constraint width 2: do nothing.
|
||||||
|
try comparison.expectApproxEqual(
|
||||||
|
glyph_25A0,
|
||||||
|
constraint.constrain(glyph_25A0, metrics, 2),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emoji (same constraint as hardcoded in SharedGrid.renderGlyph).
|
||||||
|
{
|
||||||
|
const constraint: RenderOptions.Constraint = .{
|
||||||
|
.size = .cover,
|
||||||
|
.align_horizontal = .center,
|
||||||
|
.align_vertical = .center,
|
||||||
|
.pad_left = 0.025,
|
||||||
|
.pad_right = 0.025,
|
||||||
|
};
|
||||||
|
|
||||||
|
// BBox of '🥸' (0x1F978) from Apple Color Emoji.
|
||||||
|
const glyph_1F978: GlyphSize = .{
|
||||||
|
.width = 20,
|
||||||
|
.height = 20,
|
||||||
|
.x = 0.46,
|
||||||
|
.y = 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Constraint width 2: scale to cover two cells with padding, center;
|
||||||
|
try comparison.expectApproxEqual(
|
||||||
|
GlyphSize{
|
||||||
|
.width = 18.72,
|
||||||
|
.height = 18.72,
|
||||||
|
.x = 0.44,
|
||||||
|
.y = 1.4,
|
||||||
|
},
|
||||||
|
constraint.constrain(glyph_1F978, metrics, 2),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nerd Font default.
|
||||||
|
{
|
||||||
|
const constraint = getConstraint(0xea61).?;
|
||||||
|
|
||||||
|
// Verify that this is the constraint we expect.
|
||||||
|
try std.testing.expectEqual(.fit_cover1, constraint.size);
|
||||||
|
try std.testing.expectEqual(.icon, constraint.height);
|
||||||
|
try std.testing.expectEqual(.center1, constraint.align_horizontal);
|
||||||
|
try std.testing.expectEqual(.center1, constraint.align_vertical);
|
||||||
|
|
||||||
|
// BBox of '' (0xEA61 nf-cod-lightbulb) from Symbols Only.
|
||||||
|
// NOTE: This icon is part of a group, so the
|
||||||
|
// constraint applies to a larger bounding box.
|
||||||
|
const glyph_EA61: GlyphSize = .{
|
||||||
|
.width = 9.015625,
|
||||||
|
.height = 13.015625,
|
||||||
|
.x = 3.015625,
|
||||||
|
.y = 3.76525,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Constraint width 1: scale and shift group to fit a single cell.
|
||||||
|
try comparison.expectApproxEqual(
|
||||||
|
GlyphSize{
|
||||||
|
.width = 7.2125,
|
||||||
|
.height = 10.4125,
|
||||||
|
.x = 0.8125,
|
||||||
|
.y = 5.950695224719102,
|
||||||
|
},
|
||||||
|
constraint.constrain(glyph_EA61, metrics, 1),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Constraint width 2: no scaling; left-align and vertically center group.
|
||||||
|
try comparison.expectApproxEqual(
|
||||||
|
GlyphSize{
|
||||||
|
.width = glyph_EA61.width,
|
||||||
|
.height = glyph_EA61.height,
|
||||||
|
.x = 1.015625,
|
||||||
|
.y = 4.7483690308988775,
|
||||||
|
},
|
||||||
|
constraint.constrain(glyph_EA61, metrics, 2),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nerd Font stretch.
|
||||||
|
{
|
||||||
|
const constraint = getConstraint(0xe0c0).?;
|
||||||
|
|
||||||
|
// Verify that this is the constraint we expect.
|
||||||
|
try std.testing.expectEqual(.stretch, constraint.size);
|
||||||
|
try std.testing.expectEqual(.cell, constraint.height);
|
||||||
|
try std.testing.expectEqual(.start, constraint.align_horizontal);
|
||||||
|
try std.testing.expectEqual(.center1, constraint.align_vertical);
|
||||||
|
|
||||||
|
// BBox of ' ' (0xE0C0 nf-ple-flame_thick) from Symbols Only.
|
||||||
|
const glyph_E0C0: GlyphSize = .{
|
||||||
|
.width = 16.796875,
|
||||||
|
.height = 16.46875,
|
||||||
|
.x = -0.796875,
|
||||||
|
.y = 1.7109375,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Constraint width 1: stretch and position to exactly cover one cell.
|
||||||
|
try comparison.expectApproxEqual(
|
||||||
|
GlyphSize{
|
||||||
|
.width = @floatFromInt(metrics.cell_width),
|
||||||
|
.height = @floatFromInt(metrics.cell_height),
|
||||||
|
.x = 0,
|
||||||
|
.y = 0,
|
||||||
|
},
|
||||||
|
constraint.constrain(glyph_E0C0, metrics, 1),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Constraint width 1: stretch and position to exactly cover two cells.
|
||||||
|
try comparison.expectApproxEqual(
|
||||||
|
GlyphSize{
|
||||||
|
.width = @floatFromInt(2 * metrics.cell_width),
|
||||||
|
.height = @floatFromInt(metrics.cell_height),
|
||||||
|
.x = 0,
|
||||||
|
.y = 0,
|
||||||
|
},
|
||||||
|
constraint.constrain(glyph_E0C0, metrics, 2),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -319,17 +319,6 @@ pub const Face = struct {
|
||||||
rect.origin.y -= line_width / 2;
|
rect.origin.y -= line_width / 2;
|
||||||
};
|
};
|
||||||
|
|
||||||
// We make an assumption that font smoothing ("thicken")
|
|
||||||
// adds no more than 1 extra pixel to any edge. We don't
|
|
||||||
// add extra size if it's a sbix color font though, since
|
|
||||||
// bitmaps aren't affected by smoothing.
|
|
||||||
if (opts.thicken and !sbix) {
|
|
||||||
rect.size.width += 2.0;
|
|
||||||
rect.size.height += 2.0;
|
|
||||||
rect.origin.x -= 1.0;
|
|
||||||
rect.origin.y -= 1.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If our rect is smaller than a quarter pixel in either axis
|
// If our rect is smaller than a quarter pixel in either axis
|
||||||
// then it has no outlines or they're too small to render.
|
// then it has no outlines or they're too small to render.
|
||||||
//
|
//
|
||||||
|
|
@ -349,14 +338,7 @@ pub const Face = struct {
|
||||||
const cell_height: f64 = @floatFromInt(metrics.cell_height);
|
const cell_height: f64 = @floatFromInt(metrics.cell_height);
|
||||||
|
|
||||||
// Next we apply any constraints to get the final size of the glyph.
|
// Next we apply any constraints to get the final size of the glyph.
|
||||||
var constraint = opts.constraint;
|
const constraint = opts.constraint;
|
||||||
|
|
||||||
// We eliminate any negative vertical padding since these overlap
|
|
||||||
// values aren't needed with how precisely we apply constraints,
|
|
||||||
// and they can lead to extra height that looks bad for things like
|
|
||||||
// powerline glyphs.
|
|
||||||
constraint.pad_top = @max(0.0, constraint.pad_top);
|
|
||||||
constraint.pad_bottom = @max(0.0, constraint.pad_bottom);
|
|
||||||
|
|
||||||
// We need to add the baseline position before passing to the constrain
|
// We need to add the baseline position before passing to the constrain
|
||||||
// function since it operates on cell-relative positions, not baseline.
|
// function since it operates on cell-relative positions, not baseline.
|
||||||
|
|
@ -378,6 +360,18 @@ pub const Face = struct {
|
||||||
var width = glyph_size.width;
|
var width = glyph_size.width;
|
||||||
var height = glyph_size.height;
|
var height = glyph_size.height;
|
||||||
|
|
||||||
|
// We center all glyphs within the pixel-rounded and adjusted
|
||||||
|
// cell width if it's larger than the face width, so that they
|
||||||
|
// aren't weirdly off to the left.
|
||||||
|
//
|
||||||
|
// We don't do this if the glyph has a stretch constraint,
|
||||||
|
// since in that case the position was already calculated with the
|
||||||
|
// new cell width in mind.
|
||||||
|
if ((constraint.size != .stretch) and (metrics.face_width < cell_width)) {
|
||||||
|
// We add half the difference to re-center.
|
||||||
|
x += (cell_width - metrics.face_width) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
// If this is a bitmap glyph, it will always render as full pixels,
|
// If this is a bitmap glyph, it will always render as full pixels,
|
||||||
// not fractional pixels, so we need to quantize its position and
|
// not fractional pixels, so we need to quantize its position and
|
||||||
// size accordingly to align to full pixels so we get good results.
|
// size accordingly to align to full pixels so we get good results.
|
||||||
|
|
@ -388,25 +382,16 @@ pub const Face = struct {
|
||||||
y = @round(y);
|
y = @round(y);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the cell width was adjusted wider, we re-center all glyphs
|
// We make an assumption that font smoothing ("thicken")
|
||||||
// in the new width, so that they aren't weirdly off to the left.
|
// adds no more than 1 extra pixel to any edge. We don't
|
||||||
if (metrics.original_cell_width) |original| recenter: {
|
// add extra size if it's a sbix color font though, since
|
||||||
// We don't do this if the constraint has a horizontal alignment,
|
// bitmaps aren't affected by smoothing.
|
||||||
// since in that case the position was already calculated with the
|
const canvas_padding: u32 = if (opts.thicken and !sbix) 1 else 0;
|
||||||
// new cell width in mind.
|
|
||||||
if (opts.constraint.align_horizontal != .none) break :recenter;
|
|
||||||
|
|
||||||
// If the original width was wider then we don't do anything.
|
|
||||||
if (original >= metrics.cell_width) break :recenter;
|
|
||||||
|
|
||||||
// We add half the difference to re-center.
|
|
||||||
x += (cell_width - @as(f64, @floatFromInt(original))) / 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Our whole-pixel bearings for the final glyph.
|
// Our whole-pixel bearings for the final glyph.
|
||||||
// The fractional portion will be included in the rasterized position.
|
// The fractional portion will be included in the rasterized position.
|
||||||
const px_x: i32 = @intFromFloat(@floor(x));
|
const px_x = @as(i32, @intFromFloat(@floor(x))) - @as(i32, @intCast(canvas_padding));
|
||||||
const px_y: i32 = @intFromFloat(@floor(y));
|
const px_y = @as(i32, @intFromFloat(@floor(y))) - @as(i32, @intCast(canvas_padding));
|
||||||
|
|
||||||
// We keep track of the fractional part of the pixel bearings, which
|
// We keep track of the fractional part of the pixel bearings, which
|
||||||
// we will add as an offset when rasterizing to make sure we get the
|
// we will add as an offset when rasterizing to make sure we get the
|
||||||
|
|
@ -416,9 +401,9 @@ pub const Face = struct {
|
||||||
|
|
||||||
// Add the fractional pixel to the width and height and take
|
// Add the fractional pixel to the width and height and take
|
||||||
// the ceiling to get a canvas size that will definitely fit
|
// the ceiling to get a canvas size that will definitely fit
|
||||||
// our drawn glyph, including the fractional offset.
|
// our drawn glyph, including the fractional offset and font smoothing.
|
||||||
const px_width: u32 = @intFromFloat(@ceil(width + frac_x));
|
const px_width = @as(u32, @intFromFloat(@ceil(width + frac_x))) + (2 * canvas_padding);
|
||||||
const px_height: u32 = @intFromFloat(@ceil(height + frac_y));
|
const px_height = @as(u32, @intFromFloat(@ceil(height + frac_y))) + (2 * canvas_padding);
|
||||||
|
|
||||||
// Settings that are specific to if we are rendering text or emoji.
|
// Settings that are specific to if we are rendering text or emoji.
|
||||||
const color: struct {
|
const color: struct {
|
||||||
|
|
@ -529,8 +514,8 @@ pub const Face = struct {
|
||||||
// `drawGlyphs`, we pass the negated bearings.
|
// `drawGlyphs`, we pass the negated bearings.
|
||||||
context.translateCTM(
|
context.translateCTM(
|
||||||
ctx,
|
ctx,
|
||||||
frac_x,
|
frac_x + @as(f64, @floatFromInt(canvas_padding)),
|
||||||
frac_y,
|
frac_y + @as(f64, @floatFromInt(canvas_padding)),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Scale the drawing context so that when we draw
|
// Scale the drawing context so that when we draw
|
||||||
|
|
@ -775,7 +760,10 @@ pub const Face = struct {
|
||||||
// Cell width is calculated by calculating the widest width of the
|
// Cell width is calculated by calculating the widest width of the
|
||||||
// visible ASCII characters. Usually 'M' is widest but we just take
|
// visible ASCII characters. Usually 'M' is widest but we just take
|
||||||
// whatever is widest.
|
// whatever is widest.
|
||||||
const cell_width: f64 = cell_width: {
|
//
|
||||||
|
// ASCII height is calculated as the height of the overall bounding
|
||||||
|
// box of the same characters.
|
||||||
|
const cell_width: f64, const ascii_height: f64 = measurements: {
|
||||||
// Build a comptime array of all the ASCII chars
|
// Build a comptime array of all the ASCII chars
|
||||||
const unichars = comptime unichars: {
|
const unichars = comptime unichars: {
|
||||||
const len = 127 - 32;
|
const len = 127 - 32;
|
||||||
|
|
@ -803,7 +791,10 @@ pub const Face = struct {
|
||||||
max = @max(advances[i].width, max);
|
max = @max(advances[i].width, max);
|
||||||
}
|
}
|
||||||
|
|
||||||
break :cell_width max;
|
// Get the overall bounding rect for the glyphs
|
||||||
|
const rect = ct_font.getBoundingRectsForGlyphs(.horizontal, &glyphs, null);
|
||||||
|
|
||||||
|
break :measurements .{ max, rect.size.height };
|
||||||
};
|
};
|
||||||
|
|
||||||
// Measure "水" (CJK water ideograph, U+6C34) for our ic width.
|
// Measure "水" (CJK water ideograph, U+6C34) for our ic width.
|
||||||
|
|
@ -864,6 +855,7 @@ pub const Face = struct {
|
||||||
|
|
||||||
.cap_height = cap_height,
|
.cap_height = cap_height,
|
||||||
.ex_height = ex_height,
|
.ex_height = ex_height,
|
||||||
|
.ascii_height = ascii_height,
|
||||||
.ic_width = ic_width,
|
.ic_width = ic_width,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -170,7 +170,7 @@ pub const Face = struct {
|
||||||
if (string.len > 1024) break :skip;
|
if (string.len > 1024) break :skip;
|
||||||
var tmp: [512]u16 = undefined;
|
var tmp: [512]u16 = undefined;
|
||||||
const max = string.len / 2;
|
const max = string.len / 2;
|
||||||
for (@as([]const u16, @alignCast(@ptrCast(string))), 0..) |c, j| tmp[j] = @byteSwap(c);
|
for (@as([]const u16, @ptrCast(@alignCast(string))), 0..) |c, j| tmp[j] = @byteSwap(c);
|
||||||
const len = std.unicode.utf16LeToUtf8(buf, tmp[0..max]) catch return string;
|
const len = std.unicode.utf16LeToUtf8(buf, tmp[0..max]) catch return string;
|
||||||
return buf[0..len];
|
return buf[0..len];
|
||||||
}
|
}
|
||||||
|
|
@ -351,26 +351,16 @@ pub const Face = struct {
|
||||||
return glyph.*.bitmap.pixel_mode == freetype.c.FT_PIXEL_MODE_BGRA;
|
return glyph.*.bitmap.pixel_mode == freetype.c.FT_PIXEL_MODE_BGRA;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render a glyph using the glyph index. The rendered glyph is stored in the
|
/// Set the load flags to use when loading a glyph for measurement or
|
||||||
/// given texture atlas.
|
/// rendering.
|
||||||
pub fn renderGlyph(
|
fn glyphLoadFlags(self: Face, constrained: bool) freetype.LoadFlags {
|
||||||
self: Face,
|
|
||||||
alloc: Allocator,
|
|
||||||
atlas: *font.Atlas,
|
|
||||||
glyph_index: u32,
|
|
||||||
opts: font.face.RenderOptions,
|
|
||||||
) !Glyph {
|
|
||||||
self.ft_mutex.lock();
|
|
||||||
defer self.ft_mutex.unlock();
|
|
||||||
|
|
||||||
// Hinting should only be enabled if the configured load flags specify
|
// Hinting should only be enabled if the configured load flags specify
|
||||||
// it and the provided constraint doesn't actually do anything, since
|
// it and the provided constraint doesn't actually do anything, since
|
||||||
// if it does, then it'll mess up the hinting anyway when it moves or
|
// if it does, then it'll mess up the hinting anyway when it moves or
|
||||||
// resizes the glyph.
|
// resizes the glyph.
|
||||||
const do_hinting = self.load_flags.hinting and !opts.constraint.doesAnything();
|
const do_hinting = self.load_flags.hinting and !constrained;
|
||||||
|
|
||||||
// Load the glyph.
|
return .{
|
||||||
try self.face.loadGlyph(glyph_index, .{
|
|
||||||
// If our glyph has color, we want to render the color
|
// If our glyph has color, we want to render the color
|
||||||
.color = self.face.hasColor(),
|
.color = self.face.hasColor(),
|
||||||
|
|
||||||
|
|
@ -392,42 +382,67 @@ pub const Face = struct {
|
||||||
// SVG glyphs under FreeType, since that requires bundling another
|
// SVG glyphs under FreeType, since that requires bundling another
|
||||||
// dependency to handle rendering the SVG.
|
// dependency to handle rendering the SVG.
|
||||||
.no_svg = true,
|
.no_svg = true,
|
||||||
});
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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;
|
const glyph = self.face.handle.*.glyph;
|
||||||
|
|
||||||
|
// For synthetic bold, we embolden the glyph.
|
||||||
|
if (self.synthetic.bold) {
|
||||||
|
// We need to scale the embolden amount based on the font size.
|
||||||
|
// This is a heuristic I found worked well across a variety of
|
||||||
|
// founts: 1 pixel per 64 units of height.
|
||||||
|
const font_height: f64 = @floatFromInt(self.face.handle.*.size.*.metrics.height);
|
||||||
|
const ratio: f64 = 64.0 / 2048.0;
|
||||||
|
const amount = @ceil(font_height * ratio);
|
||||||
|
_ = freetype.c.FT_Outline_Embolden(&glyph.*.outline, @intFromFloat(amount));
|
||||||
|
}
|
||||||
|
|
||||||
// We get a rect that represents the position
|
// We get a rect that represents the position
|
||||||
// and size of the glyph before any changes.
|
// and size of the glyph before constraints.
|
||||||
const rect: struct {
|
const rect = getGlyphSize(glyph);
|
||||||
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),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// If our glyph is smaller than a quarter pixel in either axis
|
// If our glyph is smaller than a quarter pixel in either axis
|
||||||
// then it has no outlines or they're too small to render.
|
// then it has no outlines or they're too small to render.
|
||||||
|
|
@ -443,30 +458,12 @@ pub const Face = struct {
|
||||||
.atlas_y = 0,
|
.atlas_y = 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
// For synthetic bold, we embolden the glyph.
|
|
||||||
if (self.synthetic.bold) {
|
|
||||||
// We need to scale the embolden amount based on the font size.
|
|
||||||
// This is a heuristic I found worked well across a variety of
|
|
||||||
// founts: 1 pixel per 64 units of height.
|
|
||||||
const font_height: f64 = @floatFromInt(self.face.handle.*.size.*.metrics.height);
|
|
||||||
const ratio: f64 = 64.0 / 2048.0;
|
|
||||||
const amount = @ceil(font_height * ratio);
|
|
||||||
_ = freetype.c.FT_Outline_Embolden(&glyph.*.outline, @intFromFloat(amount));
|
|
||||||
}
|
|
||||||
|
|
||||||
const metrics = opts.grid_metrics;
|
const metrics = opts.grid_metrics;
|
||||||
const cell_width: f64 = @floatFromInt(metrics.cell_width);
|
const cell_width: f64 = @floatFromInt(metrics.cell_width);
|
||||||
const cell_height: f64 = @floatFromInt(metrics.cell_height);
|
const cell_height: f64 = @floatFromInt(metrics.cell_height);
|
||||||
|
|
||||||
// Next we apply any constraints to get the final size of the glyph.
|
// Next we apply any constraints to get the final size of the glyph.
|
||||||
var constraint = opts.constraint;
|
const constraint = opts.constraint;
|
||||||
|
|
||||||
// We eliminate any negative vertical padding since these overlap
|
|
||||||
// values aren't needed with how precisely we apply constraints,
|
|
||||||
// and they can lead to extra height that looks bad for things like
|
|
||||||
// powerline glyphs.
|
|
||||||
constraint.pad_top = @max(0.0, constraint.pad_top);
|
|
||||||
constraint.pad_bottom = @max(0.0, constraint.pad_bottom);
|
|
||||||
|
|
||||||
// We need to add the baseline position before passing to the constrain
|
// We need to add the baseline position before passing to the constrain
|
||||||
// function since it operates on cell-relative positions, not baseline.
|
// function since it operates on cell-relative positions, not baseline.
|
||||||
|
|
@ -488,6 +485,24 @@ pub const Face = struct {
|
||||||
var x = glyph_size.x;
|
var x = glyph_size.x;
|
||||||
var y = glyph_size.y;
|
var y = glyph_size.y;
|
||||||
|
|
||||||
|
// We center all glyphs within the pixel-rounded and adjusted
|
||||||
|
// cell width if it's larger than the face width, so that they
|
||||||
|
// aren't weirdly off to the left.
|
||||||
|
//
|
||||||
|
// We don't do this if the glyph has a stretch constraint,
|
||||||
|
// since in that case the position was already calculated with the
|
||||||
|
// new cell width in mind.
|
||||||
|
if ((constraint.size != .stretch) and (metrics.face_width < cell_width)) {
|
||||||
|
// We add half the difference to re-center.
|
||||||
|
//
|
||||||
|
// NOTE: We round this to a whole-pixel amount because under
|
||||||
|
// FreeType, the outlines will be hinted, which isn't
|
||||||
|
// the case under CoreText. If we move the outlines by
|
||||||
|
// a non-whole-pixel amount, it completely ruins the
|
||||||
|
// hinting.
|
||||||
|
x += @round((cell_width - metrics.face_width) / 2);
|
||||||
|
}
|
||||||
|
|
||||||
// If this is a bitmap glyph, it will always render as full pixels,
|
// If this is a bitmap glyph, it will always render as full pixels,
|
||||||
// not fractional pixels, so we need to quantize its position and
|
// not fractional pixels, so we need to quantize its position and
|
||||||
// size accordingly to align to full pixels so we get good results.
|
// size accordingly to align to full pixels so we get good results.
|
||||||
|
|
@ -498,27 +513,6 @@ pub const Face = struct {
|
||||||
y = @round(y);
|
y = @round(y);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the cell width was adjusted wider, we re-center all glyphs
|
|
||||||
// in the new width, so that they aren't weirdly off to the left.
|
|
||||||
if (metrics.original_cell_width) |original| recenter: {
|
|
||||||
// We don't do this if the constraint has a horizontal alignment,
|
|
||||||
// since in that case the position was already calculated with the
|
|
||||||
// new cell width in mind.
|
|
||||||
if (opts.constraint.align_horizontal != .none) break :recenter;
|
|
||||||
|
|
||||||
// If the original width was wider then we don't do anything.
|
|
||||||
if (original >= metrics.cell_width) break :recenter;
|
|
||||||
|
|
||||||
// We add half the difference to re-center.
|
|
||||||
//
|
|
||||||
// NOTE: We round this to a whole-pixel amount because under
|
|
||||||
// FreeType, the outlines will be hinted, which isn't
|
|
||||||
// the case under CoreText. If we move the outlines by
|
|
||||||
// a non-whole-pixel amount, it completely ruins the
|
|
||||||
// hinting.
|
|
||||||
x += @round((cell_width - @as(f64, @floatFromInt(original))) / 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Now we can render the glyph.
|
// Now we can render the glyph.
|
||||||
var bitmap: freetype.c.FT_Bitmap = undefined;
|
var bitmap: freetype.c.FT_Bitmap = undefined;
|
||||||
_ = freetype.c.FT_Bitmap_Init(&bitmap);
|
_ = freetype.c.FT_Bitmap_Init(&bitmap);
|
||||||
|
|
@ -960,34 +954,49 @@ pub const Face = struct {
|
||||||
// visible ASCII characters. Usually 'M' is widest but we just take
|
// visible ASCII characters. Usually 'M' is widest but we just take
|
||||||
// whatever is widest.
|
// whatever is widest.
|
||||||
//
|
//
|
||||||
|
// ASCII height is calculated as the height of the overall bounding
|
||||||
|
// box of the same characters.
|
||||||
|
//
|
||||||
// If we fail to load any visible ASCII we just use max_advance from
|
// If we fail to load any visible ASCII we just use max_advance from
|
||||||
// the metrics provided by FreeType.
|
// the metrics provided by FreeType, and set ascii_height to null as
|
||||||
const cell_width: f64 = cell_width: {
|
// it's optional.
|
||||||
|
const cell_width: f64, const ascii_height: ?f64 = measurements: {
|
||||||
self.ft_mutex.lock();
|
self.ft_mutex.lock();
|
||||||
defer self.ft_mutex.unlock();
|
defer self.ft_mutex.unlock();
|
||||||
|
|
||||||
var max: f64 = 0.0;
|
var max: f64 = 0.0;
|
||||||
|
var top: f64 = 0.0;
|
||||||
|
var bottom: f64 = 0.0;
|
||||||
var c: u8 = ' ';
|
var c: u8 = ' ';
|
||||||
while (c < 127) : (c += 1) {
|
while (c < 127) : (c += 1) {
|
||||||
if (face.getCharIndex(c)) |glyph_index| {
|
if (face.getCharIndex(c)) |glyph_index| {
|
||||||
if (face.loadGlyph(glyph_index, .{
|
if (face.loadGlyph(glyph_index, self.glyphLoadFlags(false))) {
|
||||||
.render = false,
|
const glyph = face.handle.*.glyph;
|
||||||
.no_svg = true,
|
|
||||||
})) {
|
|
||||||
max = @max(
|
max = @max(
|
||||||
f26dot6ToF64(face.handle.*.glyph.*.advance.x),
|
f26dot6ToF64(glyph.*.advance.x),
|
||||||
max,
|
max,
|
||||||
);
|
);
|
||||||
|
const rect = getGlyphSize(glyph);
|
||||||
|
top = @max(rect.y + rect.height, top);
|
||||||
|
bottom = @min(rect.y, bottom);
|
||||||
} else |_| {}
|
} else |_| {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we couldn't get any widths, just use FreeType's max_advance.
|
// If we couldn't get valid measurements, just use
|
||||||
|
// FreeType's max_advance and null, respectively.
|
||||||
if (max == 0.0) {
|
if (max == 0.0) {
|
||||||
break :cell_width f26dot6ToF64(size_metrics.max_advance);
|
max = f26dot6ToF64(size_metrics.max_advance);
|
||||||
}
|
}
|
||||||
|
const rect_height: ?f64 = rect_height: {
|
||||||
|
const estimate = top - bottom;
|
||||||
|
if (estimate <= 0.0) {
|
||||||
|
break :rect_height null;
|
||||||
|
}
|
||||||
|
break :rect_height estimate;
|
||||||
|
};
|
||||||
|
|
||||||
break :cell_width max;
|
break :measurements .{ max, rect_height };
|
||||||
};
|
};
|
||||||
|
|
||||||
// We use the cap and ex heights specified by the font if they're
|
// We use the cap and ex heights specified by the font if they're
|
||||||
|
|
@ -1008,11 +1017,8 @@ pub const Face = struct {
|
||||||
self.ft_mutex.lock();
|
self.ft_mutex.lock();
|
||||||
defer self.ft_mutex.unlock();
|
defer self.ft_mutex.unlock();
|
||||||
if (face.getCharIndex('H')) |glyph_index| {
|
if (face.getCharIndex('H')) |glyph_index| {
|
||||||
if (face.loadGlyph(glyph_index, .{
|
if (face.loadGlyph(glyph_index, self.glyphLoadFlags(false))) {
|
||||||
.render = false,
|
break :cap getGlyphSize(face.handle.*.glyph).height;
|
||||||
.no_svg = true,
|
|
||||||
})) {
|
|
||||||
break :cap f26dot6ToF64(face.handle.*.glyph.*.metrics.height);
|
|
||||||
} else |_| {}
|
} else |_| {}
|
||||||
}
|
}
|
||||||
break :cap null;
|
break :cap null;
|
||||||
|
|
@ -1021,11 +1027,8 @@ pub const Face = struct {
|
||||||
self.ft_mutex.lock();
|
self.ft_mutex.lock();
|
||||||
defer self.ft_mutex.unlock();
|
defer self.ft_mutex.unlock();
|
||||||
if (face.getCharIndex('x')) |glyph_index| {
|
if (face.getCharIndex('x')) |glyph_index| {
|
||||||
if (face.loadGlyph(glyph_index, .{
|
if (face.loadGlyph(glyph_index, self.glyphLoadFlags(false))) {
|
||||||
.render = false,
|
break :ex getGlyphSize(face.handle.*.glyph).height;
|
||||||
.no_svg = true,
|
|
||||||
})) {
|
|
||||||
break :ex f26dot6ToF64(face.handle.*.glyph.*.metrics.height);
|
|
||||||
} else |_| {}
|
} else |_| {}
|
||||||
}
|
}
|
||||||
break :ex null;
|
break :ex null;
|
||||||
|
|
@ -1040,10 +1043,7 @@ pub const Face = struct {
|
||||||
|
|
||||||
const glyph = face.getCharIndex('水') orelse break :ic_width null;
|
const glyph = face.getCharIndex('水') orelse break :ic_width null;
|
||||||
|
|
||||||
face.loadGlyph(glyph, .{
|
face.loadGlyph(glyph, self.glyphLoadFlags(false)) catch break :ic_width null;
|
||||||
.render = false,
|
|
||||||
.no_svg = true,
|
|
||||||
}) catch break :ic_width null;
|
|
||||||
|
|
||||||
const ft_glyph = face.handle.*.glyph;
|
const ft_glyph = face.handle.*.glyph;
|
||||||
|
|
||||||
|
|
@ -1055,21 +1055,19 @@ pub const Face = struct {
|
||||||
// This can sometimes happen if there's a CJK font that has been
|
// This can sometimes happen if there's a CJK font that has been
|
||||||
// patched with the nerd fonts patcher and it butchers the advance
|
// patched with the nerd fonts patcher and it butchers the advance
|
||||||
// values so the advance ends up half the width of the actual glyph.
|
// values so the advance ends up half the width of the actual glyph.
|
||||||
if (ft_glyph.*.metrics.width > ft_glyph.*.advance.x) {
|
const ft_glyph_width = getGlyphSize(ft_glyph).width;
|
||||||
|
const advance = f26dot6ToF64(ft_glyph.*.advance.x);
|
||||||
|
if (ft_glyph_width > advance) {
|
||||||
var buf: [1024]u8 = undefined;
|
var buf: [1024]u8 = undefined;
|
||||||
const font_name = self.name(&buf) catch "<Error getting font name>";
|
const font_name = self.name(&buf) catch "<Error getting font name>";
|
||||||
log.warn(
|
log.warn(
|
||||||
"(getMetrics) Width of glyph '水' for font \"{s}\" is greater than its advance ({d} > {d}), discarding ic_width metric.",
|
"(getMetrics) Width of glyph '水' for font \"{s}\" is greater than its advance ({d} > {d}), discarding ic_width metric.",
|
||||||
.{
|
.{ font_name, ft_glyph_width, advance },
|
||||||
font_name,
|
|
||||||
f26dot6ToF64(ft_glyph.*.metrics.width),
|
|
||||||
f26dot6ToF64(ft_glyph.*.advance.x),
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
break :ic_width null;
|
break :ic_width null;
|
||||||
}
|
}
|
||||||
|
|
||||||
break :ic_width f26dot6ToF64(ft_glyph.*.advance.x);
|
break :ic_width advance;
|
||||||
};
|
};
|
||||||
|
|
||||||
return .{
|
return .{
|
||||||
|
|
@ -1089,6 +1087,7 @@ pub const Face = struct {
|
||||||
|
|
||||||
.cap_height = cap_height,
|
.cap_height = cap_height,
|
||||||
.ex_height = ex_height,
|
.ex_height = ex_height,
|
||||||
|
.ascii_height = ascii_height,
|
||||||
.ic_width = ic_width,
|
.ic_width = ic_width,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -1178,37 +1177,6 @@ test "color emoji" {
|
||||||
const glyph_id = ft_font.glyphIndex('🥸').?;
|
const glyph_id = ft_font.glyphIndex('🥸').?;
|
||||||
try testing.expect(ft_font.isColorGlyph(glyph_id));
|
try testing.expect(ft_font.isColorGlyph(glyph_id));
|
||||||
}
|
}
|
||||||
|
|
||||||
// resize
|
|
||||||
// TODO: Comprehensive tests for constraints,
|
|
||||||
// this is just an adapted legacy test.
|
|
||||||
{
|
|
||||||
const glyph = try ft_font.renderGlyph(
|
|
||||||
alloc,
|
|
||||||
&atlas,
|
|
||||||
ft_font.glyphIndex('🥸').?,
|
|
||||||
.{ .grid_metrics = .{
|
|
||||||
.cell_width = 13,
|
|
||||||
.cell_height = 24,
|
|
||||||
.cell_baseline = 0,
|
|
||||||
.underline_position = 0,
|
|
||||||
.underline_thickness = 0,
|
|
||||||
.strikethrough_position = 0,
|
|
||||||
.strikethrough_thickness = 0,
|
|
||||||
.overline_position = 0,
|
|
||||||
.overline_thickness = 0,
|
|
||||||
.box_thickness = 0,
|
|
||||||
.cursor_height = 0,
|
|
||||||
.icon_height = 0,
|
|
||||||
}, .constraint_width = 2, .constraint = .{
|
|
||||||
.size_horizontal = .cover,
|
|
||||||
.size_vertical = .cover,
|
|
||||||
.align_horizontal = .center,
|
|
||||||
.align_vertical = .center,
|
|
||||||
} },
|
|
||||||
);
|
|
||||||
try testing.expectEqual(@as(u32, 24), glyph.height);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
test "mono to bgra" {
|
test "mono to bgra" {
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -15,13 +15,14 @@ SymbolsNerdFont (not Mono!) font is passed as the first argument to it.
|
||||||
import ast
|
import ast
|
||||||
import sys
|
import sys
|
||||||
import math
|
import math
|
||||||
from fontTools.ttLib import TTFont
|
from fontTools.ttLib import TTFont, TTLibError
|
||||||
from fontTools.pens.boundsPen import BoundsPen
|
from fontTools.pens.boundsPen import BoundsPen
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from typing import Literal, TypedDict, cast
|
from typing import Literal, TypedDict, cast
|
||||||
|
from urllib.request import urlretrieve
|
||||||
|
|
||||||
type PatchSetAttributes = dict[Literal["default"] | int, PatchSetAttributeEntry]
|
type PatchSetAttributes = dict[Literal["default"] | int, PatchSetAttributeEntry]
|
||||||
type AttributeHash = tuple[
|
type AttributeHash = tuple[
|
||||||
|
|
@ -50,13 +51,16 @@ class PatchSetAttributeEntry(TypedDict):
|
||||||
stretch: str
|
stretch: str
|
||||||
params: dict[str, float | bool]
|
params: dict[str, float | bool]
|
||||||
|
|
||||||
group_x: float
|
relative_x: float
|
||||||
group_y: float
|
relative_y: float
|
||||||
group_width: float
|
relative_width: float
|
||||||
group_height: float
|
relative_height: float
|
||||||
|
|
||||||
|
|
||||||
class PatchSet(TypedDict):
|
class PatchSet(TypedDict):
|
||||||
|
Name: str
|
||||||
|
Filename: str
|
||||||
|
Exact: bool
|
||||||
SymStart: int
|
SymStart: int
|
||||||
SymEnd: int
|
SymEnd: int
|
||||||
SrcStart: int | None
|
SrcStart: int | None
|
||||||
|
|
@ -68,6 +72,18 @@ class PatchSetExtractor(ast.NodeVisitor):
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.symbol_table: dict[str, ast.expr] = {}
|
self.symbol_table: dict[str, ast.expr] = {}
|
||||||
self.patch_set_values: list[PatchSet] = []
|
self.patch_set_values: list[PatchSet] = []
|
||||||
|
self.nf_version: str = ""
|
||||||
|
|
||||||
|
def visit_Assign(self, node):
|
||||||
|
if (
|
||||||
|
node.col_offset == 0 # top-level assignment
|
||||||
|
and len(node.targets) == 1 # no funny destructuring business
|
||||||
|
and isinstance(node.targets[0], ast.Name) # no setitem et cetera
|
||||||
|
and node.targets[0].id == "version" # it's the version string!
|
||||||
|
):
|
||||||
|
self.nf_version = ast.literal_eval(node.value)
|
||||||
|
else:
|
||||||
|
return self.generic_visit(node)
|
||||||
|
|
||||||
def visit_ClassDef(self, node: ast.ClassDef) -> None:
|
def visit_ClassDef(self, node: ast.ClassDef) -> None:
|
||||||
if node.name != "font_patcher":
|
if node.name != "font_patcher":
|
||||||
|
|
@ -113,37 +129,56 @@ class PatchSetExtractor(ast.NodeVisitor):
|
||||||
if hasattr(ast, "unparse"):
|
if hasattr(ast, "unparse"):
|
||||||
return eval(
|
return eval(
|
||||||
ast.unparse(node),
|
ast.unparse(node),
|
||||||
{"box_keep": True},
|
{"box_enabled": False, "box_keep": False},
|
||||||
{"self": SimpleNamespace(args=SimpleNamespace(careful=True))},
|
{
|
||||||
|
"self": SimpleNamespace(
|
||||||
|
args=SimpleNamespace(
|
||||||
|
careful=False,
|
||||||
|
custom=False,
|
||||||
|
fontawesome=True,
|
||||||
|
fontawesomeextension=True,
|
||||||
|
fontlogos=True,
|
||||||
|
octicons=True,
|
||||||
|
codicons=True,
|
||||||
|
powersymbols=True,
|
||||||
|
pomicons=True,
|
||||||
|
powerline=True,
|
||||||
|
powerlineextra=True,
|
||||||
|
material=True,
|
||||||
|
weather=True,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
msg = f"<cannot eval: {type(node).__name__}>"
|
msg = f"<cannot eval: {type(node).__name__}>"
|
||||||
raise ValueError(msg) from None
|
raise ValueError(msg) from None
|
||||||
|
|
||||||
def process_patch_entry(self, dict_node: ast.Dict) -> None:
|
def process_patch_entry(self, dict_node: ast.Dict) -> None:
|
||||||
entry = {}
|
entry = {}
|
||||||
disallowed_key_nodes = frozenset({"Enabled", "Name", "Filename", "Exact"})
|
|
||||||
for key_node, value_node in zip(dict_node.keys, dict_node.values):
|
for key_node, value_node in zip(dict_node.keys, dict_node.values):
|
||||||
if (
|
if isinstance(key_node, ast.Constant):
|
||||||
isinstance(key_node, ast.Constant)
|
if key_node.value == "Enabled":
|
||||||
and key_node.value not in disallowed_key_nodes
|
if self.safe_literal_eval(value_node):
|
||||||
):
|
continue # This patch set is enabled, continue to next key
|
||||||
|
else:
|
||||||
|
return # This patch set is disabled, skip
|
||||||
key = ast.literal_eval(cast("ast.Constant", key_node))
|
key = ast.literal_eval(cast("ast.Constant", key_node))
|
||||||
entry[key] = self.resolve_symbol(value_node)
|
entry[key] = self.resolve_symbol(value_node)
|
||||||
self.patch_set_values.append(cast("PatchSet", entry))
|
self.patch_set_values.append(cast("PatchSet", entry))
|
||||||
|
|
||||||
|
|
||||||
def extract_patch_set_values(source_code: str) -> list[PatchSet]:
|
def extract_patch_set_values(source_code: str) -> tuple[list[PatchSet], str]:
|
||||||
tree = ast.parse(source_code)
|
tree = ast.parse(source_code)
|
||||||
extractor = PatchSetExtractor()
|
extractor = PatchSetExtractor()
|
||||||
extractor.visit(tree)
|
extractor.visit(tree)
|
||||||
return extractor.patch_set_values
|
return extractor.patch_set_values, extractor.nf_version
|
||||||
|
|
||||||
|
|
||||||
def parse_alignment(val: str) -> str | None:
|
def parse_alignment(val: str) -> str | None:
|
||||||
return {
|
return {
|
||||||
"l": ".start",
|
"l": ".start",
|
||||||
"r": ".end",
|
"r": ".end",
|
||||||
"c": ".center",
|
"c": ".center1", # font-patcher specific centering rule, see face.zig
|
||||||
"": None,
|
"": None,
|
||||||
}.get(val, ".none")
|
}.get(val, ".none")
|
||||||
|
|
||||||
|
|
@ -158,10 +193,10 @@ def attr_key(attr: PatchSetAttributeEntry) -> AttributeHash:
|
||||||
float(params.get("overlap", 0.0)),
|
float(params.get("overlap", 0.0)),
|
||||||
float(params.get("xy-ratio", -1.0)),
|
float(params.get("xy-ratio", -1.0)),
|
||||||
float(params.get("ypadding", 0.0)),
|
float(params.get("ypadding", 0.0)),
|
||||||
float(attr.get("group_x", 0.0)),
|
float(attr.get("relative_x", 0.0)),
|
||||||
float(attr.get("group_y", 0.0)),
|
float(attr.get("relative_y", 0.0)),
|
||||||
float(attr.get("group_width", 1.0)),
|
float(attr.get("relative_width", 1.0)),
|
||||||
float(attr.get("group_height", 1.0)),
|
float(attr.get("relative_height", 1.0)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -187,10 +222,10 @@ def emit_zig_entry_multikey(codepoints: list[int], attr: PatchSetAttributeEntry)
|
||||||
stretch = attr.get("stretch", "")
|
stretch = attr.get("stretch", "")
|
||||||
params = attr.get("params", {})
|
params = attr.get("params", {})
|
||||||
|
|
||||||
group_x = attr.get("group_x", 0.0)
|
relative_x = attr.get("relative_x", 0.0)
|
||||||
group_y = attr.get("group_y", 0.0)
|
relative_y = attr.get("relative_y", 0.0)
|
||||||
group_width = attr.get("group_width", 1.0)
|
relative_width = attr.get("relative_width", 1.0)
|
||||||
group_height = attr.get("group_height", 1.0)
|
relative_height = attr.get("relative_height", 1.0)
|
||||||
|
|
||||||
overlap = params.get("overlap", 0.0)
|
overlap = params.get("overlap", 0.0)
|
||||||
xy_ratio = params.get("xy-ratio", -1.0)
|
xy_ratio = params.get("xy-ratio", -1.0)
|
||||||
|
|
@ -204,28 +239,30 @@ def emit_zig_entry_multikey(codepoints: list[int], attr: PatchSetAttributeEntry)
|
||||||
|
|
||||||
s = f"{keys}\n => .{{\n"
|
s = f"{keys}\n => .{{\n"
|
||||||
|
|
||||||
# These translations don't quite capture the way
|
# This maps the font_patcher stretch rules to a Constrain instance
|
||||||
# the actual patcher does scaling, but they're a
|
# NOTE: some comments in font_patcher indicate that only x or y
|
||||||
# good enough compromise.
|
# would also be a valid spec, but no icons use it, so we won't
|
||||||
if "xy" in stretch:
|
# support it until we have to.
|
||||||
s += " .size_horizontal = .stretch,\n"
|
if "pa" in stretch:
|
||||||
s += " .size_vertical = .stretch,\n"
|
if "!" in stretch or overlap:
|
||||||
elif "!" in stretch or "^" in stretch:
|
s += " .size = .cover,\n"
|
||||||
s += " .size_horizontal = .cover,\n"
|
else:
|
||||||
s += " .size_vertical = .fit,\n"
|
s += " .size = .fit_cover1,\n"
|
||||||
|
elif "xy" in stretch:
|
||||||
|
s += " .size = .stretch,\n"
|
||||||
else:
|
else:
|
||||||
s += " .size_horizontal = .fit,\n"
|
print(f"Warning: Unknown stretch rule {stretch}")
|
||||||
s += " .size_vertical = .fit,\n"
|
|
||||||
|
|
||||||
# `^` indicates that scaling should fill
|
# `^` indicates that scaling should use the
|
||||||
# the whole cell, not just the icon height.
|
# full cell height, not just the icon height,
|
||||||
|
# even when the constraint width is 1
|
||||||
if "^" not in stretch:
|
if "^" not in stretch:
|
||||||
s += " .height = .icon,\n"
|
s += " .height = .icon,\n"
|
||||||
|
|
||||||
# There are two cases where we want to limit the constraint width to 1:
|
# There are two cases where we want to limit the constraint width to 1:
|
||||||
# - If there's a `1` in the stretch mode string.
|
# - If there's a `1` in the stretch mode string.
|
||||||
# - If the stretch mode is `xy` and there's not an explicit `2`.
|
# - If the stretch mode is not `pa` and there's not an explicit `2`.
|
||||||
if "1" in stretch or ("xy" in stretch and "2" not in stretch):
|
if "1" in stretch or ("pa" not in stretch and "2" not in stretch):
|
||||||
s += " .max_constraint_width = 1,\n"
|
s += " .max_constraint_width = 1,\n"
|
||||||
|
|
||||||
if align is not None:
|
if align is not None:
|
||||||
|
|
@ -233,24 +270,24 @@ def emit_zig_entry_multikey(codepoints: list[int], attr: PatchSetAttributeEntry)
|
||||||
if valign is not None:
|
if valign is not None:
|
||||||
s += f" .align_vertical = {valign},\n"
|
s += f" .align_vertical = {valign},\n"
|
||||||
|
|
||||||
if group_width != 1.0:
|
if relative_width != 1.0:
|
||||||
s += f" .group_width = {group_width:.16f},\n"
|
s += f" .relative_width = {relative_width:.16f},\n"
|
||||||
if group_height != 1.0:
|
if relative_height != 1.0:
|
||||||
s += f" .group_height = {group_height:.16f},\n"
|
s += f" .relative_height = {relative_height:.16f},\n"
|
||||||
if group_x != 0.0:
|
if relative_x != 0.0:
|
||||||
s += f" .group_x = {group_x:.16f},\n"
|
s += f" .relative_x = {relative_x:.16f},\n"
|
||||||
if group_y != 0.0:
|
if relative_y != 0.0:
|
||||||
s += f" .group_y = {group_y:.16f},\n"
|
s += f" .relative_y = {relative_y:.16f},\n"
|
||||||
|
|
||||||
# `overlap` and `ypadding` are mutually exclusive,
|
# `overlap` and `ypadding` are mutually exclusive,
|
||||||
# this is asserted in the nerd fonts patcher itself.
|
# this is asserted in the nerd fonts patcher itself.
|
||||||
if overlap:
|
if overlap:
|
||||||
pad = -overlap
|
pad = -overlap / 2
|
||||||
s += f" .pad_left = {pad},\n"
|
s += f" .pad_left = {pad},\n"
|
||||||
s += f" .pad_right = {pad},\n"
|
s += f" .pad_right = {pad},\n"
|
||||||
# In the nerd fonts patcher, overlap values
|
# In the nerd fonts patcher, overlap values
|
||||||
# are capped at 0.01 in the vertical direction.
|
# are capped at 0.01 in the vertical direction.
|
||||||
v_pad = -min(0.01, overlap)
|
v_pad = -min(0.01, overlap) / 2
|
||||||
s += f" .pad_top = {v_pad},\n"
|
s += f" .pad_top = {v_pad},\n"
|
||||||
s += f" .pad_bottom = {v_pad},\n"
|
s += f" .pad_bottom = {v_pad},\n"
|
||||||
elif y_padding:
|
elif y_padding:
|
||||||
|
|
@ -264,54 +301,236 @@ def emit_zig_entry_multikey(codepoints: list[int], attr: PatchSetAttributeEntry)
|
||||||
return s
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def generate_codepoint_tables(
|
||||||
|
patch_sets: list[PatchSet],
|
||||||
|
nerd_font: TTFont,
|
||||||
|
nf_version: str,
|
||||||
|
) -> dict[str, dict[int, int]]:
|
||||||
|
# We may already have the table saved from a previous run.
|
||||||
|
if Path("nerd_font_codepoint_tables.py").exists():
|
||||||
|
import nerd_font_codepoint_tables
|
||||||
|
|
||||||
|
if nerd_font_codepoint_tables.version == nf_version:
|
||||||
|
return nerd_font_codepoint_tables.cp_tables
|
||||||
|
|
||||||
|
cp_tables: dict[str, dict[int, int]] = {}
|
||||||
|
cp_nerdfont_used: set[int] = set()
|
||||||
|
cmap = nerd_font.getBestCmap()
|
||||||
|
for entry in patch_sets:
|
||||||
|
patch_set_name = entry["Name"]
|
||||||
|
print(f"Info: Extracting codepoint table from patch set '{patch_set_name}'")
|
||||||
|
|
||||||
|
# Extract codepoint map from original font file; download if needed
|
||||||
|
source_filename = entry["Filename"]
|
||||||
|
target_folder = Path("nerd_font_symbol_fonts")
|
||||||
|
target_folder.mkdir(exist_ok=True)
|
||||||
|
target_file = target_folder / Path(source_filename).name
|
||||||
|
if not target_file.exists():
|
||||||
|
print(f"Info: Downloading '{source_filename}'")
|
||||||
|
urlretrieve(
|
||||||
|
f"https://github.com/ryanoasis/nerd-fonts/raw/refs/tags/v{nf_version}/src/glyphs/{source_filename}",
|
||||||
|
target_file,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with TTFont(target_file) as patchfont:
|
||||||
|
patch_cmap = patchfont.getBestCmap()
|
||||||
|
except TTLibError:
|
||||||
|
# Not a TTF/OTF font. This is OK if this patch set is exact, so we
|
||||||
|
# let if pass. If there's a problem, later checks will catch it.
|
||||||
|
patch_cmap = None
|
||||||
|
|
||||||
|
# A glyph's scale rules are specified using its codepoint in
|
||||||
|
# the original font, which is sometimes different from its
|
||||||
|
# Nerd Font codepoint. If entry["Exact"] is False, the codepoints are
|
||||||
|
# mapped according to the following rules:
|
||||||
|
# * entry["SymStart"] and entry["SymEnd"] denote the patch set's codepoint
|
||||||
|
# range in the original font.
|
||||||
|
# * entry["SrcStart"] is the starting point of the patch set's mapped
|
||||||
|
# codepoint range. It must not be None if entry["Exact"] is False.
|
||||||
|
# * The destination codepoint range is packed; that is, while there may be
|
||||||
|
# gaps without glyphs in the original font's codepoint range, there are
|
||||||
|
# none in the Nerd Font range. Hence there is no constant codepoint
|
||||||
|
# offset; instead we must iterate through the range and increment the
|
||||||
|
# destination codepoint every time we encounter a glyph in the original
|
||||||
|
# font.
|
||||||
|
# If entry["Exact"] is True, the origin and Nerd Font codepoints are the
|
||||||
|
# same, gaps included, and entry["SrcStart"] must be None.
|
||||||
|
if entry["Exact"]:
|
||||||
|
assert entry["SrcStart"] is None
|
||||||
|
cp_nerdfont = 0
|
||||||
|
else:
|
||||||
|
assert entry["SrcStart"]
|
||||||
|
assert patch_cmap is not None
|
||||||
|
cp_nerdfont = entry["SrcStart"] - 1
|
||||||
|
|
||||||
|
if patch_set_name not in cp_tables:
|
||||||
|
# There are several patch sets with the same name, representing
|
||||||
|
# different codepoint ranges within the same original font. Merging
|
||||||
|
# these into a single table is OK. However, we need to keep separate
|
||||||
|
# tables for the different fonts to correctly deal with cases where
|
||||||
|
# they fill in each other's gaps.
|
||||||
|
cp_tables[patch_set_name] = {}
|
||||||
|
for cp_original in range(entry["SymStart"], entry["SymEnd"] + 1):
|
||||||
|
if patch_cmap and cp_original not in patch_cmap:
|
||||||
|
continue
|
||||||
|
if not entry["Exact"]:
|
||||||
|
cp_nerdfont += 1
|
||||||
|
else:
|
||||||
|
cp_nerdfont = cp_original
|
||||||
|
if cp_nerdfont not in cmap:
|
||||||
|
raise ValueError(
|
||||||
|
f"Missing codepoint in Symbols Only Font: {hex(cp_nerdfont)} in patch set '{patch_set_name}'"
|
||||||
|
)
|
||||||
|
elif cp_nerdfont in cp_nerdfont_used:
|
||||||
|
raise ValueError(
|
||||||
|
f"Overlap for codepoint {hex(cp_nerdfont)} in patch set '{patch_set_name}'"
|
||||||
|
)
|
||||||
|
cp_tables[patch_set_name][cp_original] = cp_nerdfont
|
||||||
|
cp_nerdfont_used.add(cp_nerdfont)
|
||||||
|
|
||||||
|
# Store the table and corresponding Nerd Fonts version together in a module.
|
||||||
|
with open("nerd_font_codepoint_tables.py", "w") as f:
|
||||||
|
print(
|
||||||
|
"""#! This is a generated file, produced by nerd_font_codegen.py
|
||||||
|
#! DO NOT EDIT BY HAND!
|
||||||
|
#!
|
||||||
|
#! This file specifies the mapping of codepoints in the original symbol
|
||||||
|
#! fonts to codepoints in a patched Nerd Font. This is extracted from
|
||||||
|
#! the nerd fonts patcher script and the symbol font files.""",
|
||||||
|
file=f,
|
||||||
|
)
|
||||||
|
print(f'version = "{nf_version}"', file=f)
|
||||||
|
print("cp_tables = {", file=f)
|
||||||
|
for name, table in cp_tables.items():
|
||||||
|
print(f' "{name}": {{', file=f)
|
||||||
|
for key, value in table.items():
|
||||||
|
print(f" {hex(key)}: {hex(value)},", file=f)
|
||||||
|
print(" },", file=f)
|
||||||
|
print("}", file=f)
|
||||||
|
|
||||||
|
return cp_tables
|
||||||
|
|
||||||
|
|
||||||
def generate_zig_switch_arms(
|
def generate_zig_switch_arms(
|
||||||
patch_sets: list[PatchSet],
|
patch_sets: list[PatchSet],
|
||||||
nerd_font: TTFont,
|
nerd_font: TTFont,
|
||||||
|
nf_version: str,
|
||||||
) -> str:
|
) -> str:
|
||||||
cmap = nerd_font.getBestCmap()
|
cmap = nerd_font.getBestCmap()
|
||||||
glyphs = nerd_font.getGlyphSet()
|
glyphs = nerd_font.getGlyphSet()
|
||||||
|
cp_tables = generate_codepoint_tables(patch_sets, nerd_font, nf_version)
|
||||||
|
|
||||||
entries: dict[int, PatchSetAttributeEntry] = {}
|
entries: dict[int, PatchSetAttributeEntry] = {}
|
||||||
for entry in patch_sets:
|
for entry in patch_sets:
|
||||||
|
patch_set_name = entry["Name"]
|
||||||
|
print(f"Info: Extracting rules from patch set '{patch_set_name}'")
|
||||||
|
|
||||||
attributes = entry["Attributes"]
|
attributes = entry["Attributes"]
|
||||||
|
patch_set_entries: dict[int, PatchSetAttributeEntry] = {}
|
||||||
|
|
||||||
for cp in range(entry["SymStart"], entry["SymEnd"] + 1):
|
cp_table = cp_tables[patch_set_name]
|
||||||
entries[cp] = attributes["default"].copy()
|
for cp_original in range(entry["SymStart"], entry["SymEnd"] + 1):
|
||||||
|
if cp_original not in cp_table:
|
||||||
|
continue
|
||||||
|
cp_nerdfont = cp_table[cp_original]
|
||||||
|
if cp_nerdfont in entries:
|
||||||
|
raise ValueError(
|
||||||
|
f"Overlap for codepoint {hex(cp_nerdfont)} in patch set '{patch_set_name}'"
|
||||||
|
)
|
||||||
|
if cp_original in attributes:
|
||||||
|
patch_set_entries[cp_nerdfont] = attributes[cp_original].copy()
|
||||||
|
else:
|
||||||
|
patch_set_entries[cp_nerdfont] = attributes["default"].copy()
|
||||||
|
|
||||||
entries |= {k: v for k, v in attributes.items() if isinstance(k, int)}
|
if entry["ScaleRules"] is not None:
|
||||||
|
|
||||||
if entry["ScaleRules"] is not None and "ScaleGroups" in entry["ScaleRules"]:
|
|
||||||
for group in entry["ScaleRules"]["ScaleGroups"]:
|
for group in entry["ScaleRules"]["ScaleGroups"]:
|
||||||
xMin = math.inf
|
xMin = math.inf
|
||||||
yMin = math.inf
|
yMin = math.inf
|
||||||
xMax = -math.inf
|
xMax = -math.inf
|
||||||
yMax = -math.inf
|
yMax = -math.inf
|
||||||
individual_bounds: dict[int, tuple[int, int, int ,int]] = {}
|
individual_bounds: dict[int, tuple[int, int, int, int]] = {}
|
||||||
for cp in group:
|
individual_advances: set[float] = set()
|
||||||
if cp not in cmap:
|
for cp_original in group:
|
||||||
|
if cp_original not in cp_table:
|
||||||
|
# There is one special case where a scale group includes
|
||||||
|
# a glyph from the original font that's not in any patch
|
||||||
|
# set, and hence not in the Symbols Only font. The point
|
||||||
|
# of this glyph is to add extra vertical padding to a
|
||||||
|
# stretched (^xy) scale group, which means that its
|
||||||
|
# scaled and aligned position would span the line height
|
||||||
|
# plus overlap. Thus, we can use any other stretched
|
||||||
|
# glyph with overlap as stand-in to get the vertical
|
||||||
|
# bounds, such as as 0xE0B0 (powerline left hard
|
||||||
|
# divider). We don't worry about the horizontal bounds,
|
||||||
|
# as they by design should not affect the group's
|
||||||
|
# bounding box.
|
||||||
|
if (
|
||||||
|
patch_set_name == "Progress Indicators"
|
||||||
|
and cp_original == 0xEDFF
|
||||||
|
):
|
||||||
|
glyph = glyphs[cmap[0xE0B0]]
|
||||||
|
bounds = BoundsPen(glyphSet=glyphs)
|
||||||
|
glyph.draw(bounds)
|
||||||
|
yMin = min(bounds.bounds[1], yMin)
|
||||||
|
yMax = max(bounds.bounds[3], yMax)
|
||||||
|
else:
|
||||||
|
# Other cases are due to lazily specified scale
|
||||||
|
# groups with gaps in the codepoint range.
|
||||||
|
print(
|
||||||
|
f"Info: Skipping scale group codepoint {hex(cp_original)}, which does not exist in patch set '{patch_set_name}'"
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
glyph = glyphs[cmap[cp]]
|
|
||||||
|
cp_nerdfont = cp_table[cp_original]
|
||||||
|
glyph = glyphs[cmap[cp_nerdfont]]
|
||||||
|
individual_advances.add(glyph.width)
|
||||||
bounds = BoundsPen(glyphSet=glyphs)
|
bounds = BoundsPen(glyphSet=glyphs)
|
||||||
glyph.draw(bounds)
|
glyph.draw(bounds)
|
||||||
individual_bounds[cp] = bounds.bounds
|
individual_bounds[cp_nerdfont] = bounds.bounds
|
||||||
xMin = min(bounds.bounds[0], xMin)
|
xMin = min(bounds.bounds[0], xMin)
|
||||||
yMin = min(bounds.bounds[1], yMin)
|
yMin = min(bounds.bounds[1], yMin)
|
||||||
xMax = max(bounds.bounds[2], xMax)
|
xMax = max(bounds.bounds[2], xMax)
|
||||||
yMax = max(bounds.bounds[3], yMax)
|
yMax = max(bounds.bounds[3], yMax)
|
||||||
group_width = xMax - xMin
|
group_width = xMax - xMin
|
||||||
group_height = yMax - yMin
|
group_height = yMax - yMin
|
||||||
for cp in group:
|
group_is_monospace = (len(individual_bounds) > 1) and (
|
||||||
if cp not in cmap or cp not in entries:
|
len(individual_advances) == 1
|
||||||
|
)
|
||||||
|
for cp_original in group:
|
||||||
|
if cp_original not in cp_table:
|
||||||
continue
|
continue
|
||||||
this_bounds = individual_bounds[cp]
|
cp_nerdfont = cp_table[cp_original]
|
||||||
this_width = this_bounds[2] - this_bounds[0]
|
if (
|
||||||
|
# Scale groups may cut across patch sets, but we're only
|
||||||
|
# updating a single patch set at a time, so we skip
|
||||||
|
# codepoints not in it.
|
||||||
|
cp_nerdfont not in patch_set_entries
|
||||||
|
# Codepoints may contribute to the bounding box of multiple groups,
|
||||||
|
# but should be scaled according to the first group they are found
|
||||||
|
# in. Hence, to avoid overwriting, we need to skip codepoints that
|
||||||
|
# have already been assigned a scale group.
|
||||||
|
or "relative_height" in patch_set_entries[cp_nerdfont]
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
this_bounds = individual_bounds[cp_nerdfont]
|
||||||
this_height = this_bounds[3] - this_bounds[1]
|
this_height = this_bounds[3] - this_bounds[1]
|
||||||
entries[cp]["group_width"] = group_width / this_width
|
patch_set_entries[cp_nerdfont]["relative_height"] = (
|
||||||
entries[cp]["group_height"] = group_height / this_height
|
this_height / group_height
|
||||||
entries[cp]["group_x"] = (this_bounds[0] - xMin) / group_width
|
)
|
||||||
entries[cp]["group_y"] = (this_bounds[1] - yMin) / group_height
|
patch_set_entries[cp_nerdfont]["relative_y"] = (
|
||||||
|
this_bounds[1] - yMin
|
||||||
del entries[0]
|
) / group_height
|
||||||
|
# Horizontal alignment should only be grouped if the group is monospace,
|
||||||
|
# that is, if all glyphs in the group have the same advance width.
|
||||||
|
if group_is_monospace:
|
||||||
|
this_width = this_bounds[2] - this_bounds[0]
|
||||||
|
patch_set_entries[cp_nerdfont]["relative_width"] = (
|
||||||
|
this_width / group_width
|
||||||
|
)
|
||||||
|
patch_set_entries[cp_nerdfont]["relative_x"] = (
|
||||||
|
this_bounds[0] - xMin
|
||||||
|
) / group_width
|
||||||
|
entries |= patch_set_entries
|
||||||
|
|
||||||
# Group codepoints by attribute key
|
# Group codepoints by attribute key
|
||||||
grouped = defaultdict[AttributeHash, list[int]](list)
|
grouped = defaultdict[AttributeHash, list[int]](list)
|
||||||
|
|
@ -337,7 +556,7 @@ if __name__ == "__main__":
|
||||||
|
|
||||||
patcher_path = project_root / "vendor" / "nerd-fonts" / "font-patcher.py"
|
patcher_path = project_root / "vendor" / "nerd-fonts" / "font-patcher.py"
|
||||||
source = patcher_path.read_text(encoding="utf-8")
|
source = patcher_path.read_text(encoding="utf-8")
|
||||||
patch_set = extract_patch_set_values(source)
|
patch_set, nf_version = extract_patch_set_values(source)
|
||||||
|
|
||||||
out_path = project_root / "src" / "font" / "nerd_font_attributes.zig"
|
out_path = project_root / "src" / "font" / "nerd_font_attributes.zig"
|
||||||
|
|
||||||
|
|
@ -350,9 +569,9 @@ if __name__ == "__main__":
|
||||||
|
|
||||||
const Constraint = @import("face.zig").RenderOptions.Constraint;
|
const Constraint = @import("face.zig").RenderOptions.Constraint;
|
||||||
|
|
||||||
/// Get the a constraints for the provided codepoint.
|
/// Get the constraints for the provided codepoint.
|
||||||
pub fn getConstraint(cp: u21) ?Constraint {
|
pub fn getConstraint(cp: u21) ?Constraint {
|
||||||
return switch (cp) {
|
return switch (cp) {
|
||||||
""")
|
""")
|
||||||
f.write(generate_zig_switch_arms(patch_set, nerd_font))
|
f.write(generate_zig_switch_arms(patch_set, nerd_font, nf_version))
|
||||||
f.write("\n else => null,\n };\n}\n")
|
f.write("\n else => null,\n };\n}\n")
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -52,10 +52,10 @@ pub const Shaper = struct {
|
||||||
/// The shared memory used for shaping results.
|
/// The shared memory used for shaping results.
|
||||||
cell_buf: CellBuf,
|
cell_buf: CellBuf,
|
||||||
|
|
||||||
/// The cached writing direction value for shaping. This isn't
|
/// Cached attributes dict for creating CTTypesetter objects.
|
||||||
/// configurable we just use this as a cache to avoid creating
|
/// The values in this never change so we can avoid overhead
|
||||||
/// and releasing many objects when shaping.
|
/// by just creating it once and saving it for re-use.
|
||||||
writing_direction: *macos.foundation.Array,
|
typesetter_attr_dict: *macos.foundation.Dictionary,
|
||||||
|
|
||||||
/// List where we cache fonts, so we don't have to remake them for
|
/// List where we cache fonts, so we don't have to remake them for
|
||||||
/// every single shaping operation.
|
/// every single shaping operation.
|
||||||
|
|
@ -174,21 +174,28 @@ pub const Shaper = struct {
|
||||||
//
|
//
|
||||||
// See: https://github.com/mitchellh/ghostty/issues/1737
|
// See: https://github.com/mitchellh/ghostty/issues/1737
|
||||||
// See: https://github.com/mitchellh/ghostty/issues/1442
|
// See: https://github.com/mitchellh/ghostty/issues/1442
|
||||||
const writing_direction = array: {
|
//
|
||||||
const dir: macos.text.WritingDirection = .lro;
|
// We used to do this by setting the writing direction attribute
|
||||||
const num = try macos.foundation.Number.create(
|
// on the attributed string we used, but it seems like that will
|
||||||
.int,
|
// still allow some weird results, for example a single space at
|
||||||
&@intFromEnum(dir),
|
// the end of a line composed of RTL characters will be cause it
|
||||||
);
|
// to output a run containing just that space, BEFORE it outputs
|
||||||
|
// the rest of the line as a separate run, very weirdly with the
|
||||||
|
// "right to left" flag set in the single space run's run status...
|
||||||
|
//
|
||||||
|
// So instead what we do is use a CTTypesetter to create our line,
|
||||||
|
// using the kCTTypesetterOptionForcedEmbeddingLevel attribute to
|
||||||
|
// force CoreText not to try doing any sort of BiDi, instead just
|
||||||
|
// treat all text as embedding level 0 (left to right).
|
||||||
|
const typesetter_attr_dict = dict: {
|
||||||
|
const num = try macos.foundation.Number.create(.int, &0);
|
||||||
defer num.release();
|
defer num.release();
|
||||||
|
break :dict try macos.foundation.Dictionary.create(
|
||||||
var arr_init = [_]*const macos.foundation.Number{num};
|
&.{macos.c.kCTTypesetterOptionForcedEmbeddingLevel},
|
||||||
break :array try macos.foundation.Array.create(
|
&.{num},
|
||||||
macos.foundation.Number,
|
|
||||||
&arr_init,
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
errdefer writing_direction.release();
|
errdefer typesetter_attr_dict.release();
|
||||||
|
|
||||||
// Create the CF release thread.
|
// Create the CF release thread.
|
||||||
var cf_release_thread = try alloc.create(CFReleaseThread);
|
var cf_release_thread = try alloc.create(CFReleaseThread);
|
||||||
|
|
@ -210,7 +217,7 @@ pub const Shaper = struct {
|
||||||
.run_state = run_state,
|
.run_state = run_state,
|
||||||
.features = features,
|
.features = features,
|
||||||
.features_no_default = features_no_default,
|
.features_no_default = features_no_default,
|
||||||
.writing_direction = writing_direction,
|
.typesetter_attr_dict = typesetter_attr_dict,
|
||||||
.cached_fonts = .{},
|
.cached_fonts = .{},
|
||||||
.cached_font_grid = 0,
|
.cached_font_grid = 0,
|
||||||
.cf_release_pool = .{},
|
.cf_release_pool = .{},
|
||||||
|
|
@ -224,7 +231,7 @@ pub const Shaper = struct {
|
||||||
self.run_state.deinit(self.alloc);
|
self.run_state.deinit(self.alloc);
|
||||||
self.features.release();
|
self.features.release();
|
||||||
self.features_no_default.release();
|
self.features_no_default.release();
|
||||||
self.writing_direction.release();
|
self.typesetter_attr_dict.release();
|
||||||
|
|
||||||
{
|
{
|
||||||
for (self.cached_fonts.items) |ft| {
|
for (self.cached_fonts.items) |ft| {
|
||||||
|
|
@ -346,8 +353,8 @@ pub const Shaper = struct {
|
||||||
run.font_index,
|
run.font_index,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Make room for the attributed string and the CTLine.
|
// Make room for the attributed string, CTTypesetter, and CTLine.
|
||||||
try self.cf_release_pool.ensureUnusedCapacity(self.alloc, 3);
|
try self.cf_release_pool.ensureUnusedCapacity(self.alloc, 4);
|
||||||
|
|
||||||
const str = macos.foundation.String.createWithCharactersNoCopy(state.unichars.items);
|
const str = macos.foundation.String.createWithCharactersNoCopy(state.unichars.items);
|
||||||
self.cf_release_pool.appendAssumeCapacity(str);
|
self.cf_release_pool.appendAssumeCapacity(str);
|
||||||
|
|
@ -359,8 +366,17 @@ pub const Shaper = struct {
|
||||||
);
|
);
|
||||||
self.cf_release_pool.appendAssumeCapacity(attr_str);
|
self.cf_release_pool.appendAssumeCapacity(attr_str);
|
||||||
|
|
||||||
// We should always have one run because we do our own run splitting.
|
// Create a typesetter from the attributed string and the cached
|
||||||
const line = try macos.text.Line.createWithAttributedString(attr_str);
|
// attr dict. (See comment in init for more info on the attr dict.)
|
||||||
|
const typesetter =
|
||||||
|
try macos.text.Typesetter.createWithAttributedStringAndOptions(
|
||||||
|
attr_str,
|
||||||
|
self.typesetter_attr_dict,
|
||||||
|
);
|
||||||
|
self.cf_release_pool.appendAssumeCapacity(typesetter);
|
||||||
|
|
||||||
|
// Create a line from the typesetter
|
||||||
|
const line = typesetter.createLine(.{ .location = 0, .length = 0 });
|
||||||
self.cf_release_pool.appendAssumeCapacity(line);
|
self.cf_release_pool.appendAssumeCapacity(line);
|
||||||
|
|
||||||
// This keeps track of the current offsets within a single cell.
|
// This keeps track of the current offsets within a single cell.
|
||||||
|
|
@ -369,7 +385,12 @@ pub const Shaper = struct {
|
||||||
x: f64 = 0,
|
x: f64 = 0,
|
||||||
y: f64 = 0,
|
y: f64 = 0,
|
||||||
} = .{};
|
} = .{};
|
||||||
|
|
||||||
|
// Clear our cell buf and make sure we have enough room for the whole
|
||||||
|
// line of glyphs, so that we can just assume capacity when appending
|
||||||
|
// instead of maybe allocating.
|
||||||
self.cell_buf.clearRetainingCapacity();
|
self.cell_buf.clearRetainingCapacity();
|
||||||
|
try self.cell_buf.ensureTotalCapacity(self.alloc, line.getGlyphCount());
|
||||||
|
|
||||||
// CoreText may generate multiple runs even though our input to
|
// CoreText may generate multiple runs even though our input to
|
||||||
// CoreText is already split into runs by our own run iterator.
|
// CoreText is already split into runs by our own run iterator.
|
||||||
|
|
@ -381,9 +402,9 @@ pub const Shaper = struct {
|
||||||
const ctrun = runs.getValueAtIndex(macos.text.Run, i);
|
const ctrun = runs.getValueAtIndex(macos.text.Run, i);
|
||||||
|
|
||||||
// Get our glyphs and positions
|
// Get our glyphs and positions
|
||||||
const glyphs = try ctrun.getGlyphs(alloc);
|
const glyphs = ctrun.getGlyphsPtr() orelse try ctrun.getGlyphs(alloc);
|
||||||
const advances = try ctrun.getAdvances(alloc);
|
const advances = ctrun.getAdvancesPtr() orelse try ctrun.getAdvances(alloc);
|
||||||
const indices = try ctrun.getStringIndices(alloc);
|
const indices = ctrun.getStringIndicesPtr() orelse try ctrun.getStringIndices(alloc);
|
||||||
assert(glyphs.len == advances.len);
|
assert(glyphs.len == advances.len);
|
||||||
assert(glyphs.len == indices.len);
|
assert(glyphs.len == indices.len);
|
||||||
|
|
||||||
|
|
@ -406,7 +427,7 @@ pub const Shaper = struct {
|
||||||
cell_offset = .{ .cluster = cluster };
|
cell_offset = .{ .cluster = cluster };
|
||||||
}
|
}
|
||||||
|
|
||||||
try self.cell_buf.append(self.alloc, .{
|
self.cell_buf.appendAssumeCapacity(.{
|
||||||
.x = @intCast(cluster),
|
.x = @intCast(cluster),
|
||||||
.x_offset = @intFromFloat(@round(cell_offset.x)),
|
.x_offset = @intFromFloat(@round(cell_offset.x)),
|
||||||
.y_offset = @intFromFloat(@round(cell_offset.y)),
|
.y_offset = @intFromFloat(@round(cell_offset.y)),
|
||||||
|
|
@ -511,15 +532,10 @@ pub const Shaper = struct {
|
||||||
// Get our font and use that get the attributes to set for the
|
// Get our font and use that get the attributes to set for the
|
||||||
// attributed string so the whole string uses the same font.
|
// attributed string so the whole string uses the same font.
|
||||||
const attr_dict = dict: {
|
const attr_dict = dict: {
|
||||||
var keys = [_]?*const anyopaque{
|
break :dict try macos.foundation.Dictionary.create(
|
||||||
macos.text.StringAttribute.font.key(),
|
&.{macos.text.StringAttribute.font.key()},
|
||||||
macos.text.StringAttribute.writing_direction.key(),
|
&.{run_font},
|
||||||
};
|
);
|
||||||
var values = [_]?*const anyopaque{
|
|
||||||
run_font,
|
|
||||||
self.writing_direction,
|
|
||||||
};
|
|
||||||
break :dict try macos.foundation.Dictionary.create(&keys, &values);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
self.cached_fonts.items[index_int] = attr_dict;
|
self.cached_fonts.items[index_int] = attr_dict;
|
||||||
|
|
|
||||||
|
|
@ -64,11 +64,35 @@ pub const Parser = struct {
|
||||||
const flags, const start_idx = try parseFlags(raw_input);
|
const flags, const start_idx = try parseFlags(raw_input);
|
||||||
const input = raw_input[start_idx..];
|
const input = raw_input[start_idx..];
|
||||||
|
|
||||||
// Find the last = which splits are mapping into the trigger
|
// Find the equal sign. This is more complicated than it seems on
|
||||||
// and action, respectively.
|
// the surface because we need to ignore equal signs that are
|
||||||
// We use the last = because the keybind itself could contain
|
// part of the trigger.
|
||||||
// raw equal signs (for the = codepoint)
|
const eql_idx: usize = eql: {
|
||||||
const eql_idx = std.mem.lastIndexOf(u8, input, "=") orelse return Error.InvalidFormat;
|
// TODO: We should change this parser into a real state machine
|
||||||
|
// based parser that parses the trigger fully, then yields the
|
||||||
|
// action after. The loop below is a total mess.
|
||||||
|
var offset: usize = 0;
|
||||||
|
while (std.mem.indexOfScalar(
|
||||||
|
u8,
|
||||||
|
input[offset..],
|
||||||
|
'=',
|
||||||
|
)) |offset_idx| {
|
||||||
|
// Find: '=+ctrl' or '==action'
|
||||||
|
const idx = offset + offset_idx;
|
||||||
|
if (idx < input.len - 1 and
|
||||||
|
(input[idx + 1] == '+' or
|
||||||
|
input[idx + 1] == '='))
|
||||||
|
{
|
||||||
|
offset += offset_idx + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Looks like the real equal sign.
|
||||||
|
break :eql idx;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Error.InvalidFormat;
|
||||||
|
};
|
||||||
|
|
||||||
// Sequence iterator goes up to the equal, action is after. We can
|
// Sequence iterator goes up to the equal, action is after. We can
|
||||||
// parse the action now.
|
// parse the action now.
|
||||||
|
|
@ -698,7 +722,7 @@ pub const Action = union(enum) {
|
||||||
/// All actions are only undoable/redoable for a limited time.
|
/// All actions are only undoable/redoable for a limited time.
|
||||||
/// For example, restoring a closed split can only be done for
|
/// For example, restoring a closed split can only be done for
|
||||||
/// some number of seconds since the split was closed. The exact
|
/// some number of seconds since the split was closed. The exact
|
||||||
/// amount is configured with `TODO`.
|
/// amount is configured with the `undo-timeout` configuration settings.
|
||||||
///
|
///
|
||||||
/// The undo/redo actions being limited ensures that there is
|
/// The undo/redo actions being limited ensures that there is
|
||||||
/// bounded memory usage over time, closed surfaces don't continue running
|
/// bounded memory usage over time, closed surfaces don't continue running
|
||||||
|
|
@ -1189,7 +1213,7 @@ pub const Action = union(enum) {
|
||||||
const value_info = @typeInfo(Value);
|
const value_info = @typeInfo(Value);
|
||||||
switch (Value) {
|
switch (Value) {
|
||||||
void => {},
|
void => {},
|
||||||
[]const u8 => try writer.print("{s}", .{value}),
|
[]const u8 => try std.zig.stringEscape(value, "", .{}, writer),
|
||||||
else => switch (value_info) {
|
else => switch (value_info) {
|
||||||
.@"enum" => try writer.print("{s}", .{@tagName(value)}),
|
.@"enum" => try writer.print("{s}", .{@tagName(value)}),
|
||||||
.float => try writer.print("{d}", .{value}),
|
.float => try writer.print("{d}", .{value}),
|
||||||
|
|
@ -2298,6 +2322,39 @@ test "parse: equals sign" {
|
||||||
try testing.expectError(Error.InvalidFormat, parseSingle("=ignore"));
|
try testing.expectError(Error.InvalidFormat, parseSingle("=ignore"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test "parse: text action equals sign" {
|
||||||
|
const testing = std.testing;
|
||||||
|
{
|
||||||
|
const binding = try parseSingle("==text:=");
|
||||||
|
try testing.expectEqual(Trigger{ .key = .{ .unicode = '=' } }, binding.trigger);
|
||||||
|
try testing.expectEqualStrings("=", binding.action.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const binding = try parseSingle("==text:=hello");
|
||||||
|
try testing.expectEqual(Trigger{ .key = .{ .unicode = '=' } }, binding.trigger);
|
||||||
|
try testing.expectEqualStrings("=hello", binding.action.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const binding = try parseSingle("ctrl+==text:=hello");
|
||||||
|
try testing.expectEqual(Trigger{
|
||||||
|
.key = .{ .unicode = '=' },
|
||||||
|
.mods = .{ .ctrl = true },
|
||||||
|
}, binding.trigger);
|
||||||
|
try testing.expectEqualStrings("=hello", binding.action.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const binding = try parseSingle("=+ctrl=text:=hello");
|
||||||
|
try testing.expectEqual(Trigger{
|
||||||
|
.key = .{ .unicode = '=' },
|
||||||
|
.mods = .{ .ctrl = true },
|
||||||
|
}, binding.trigger);
|
||||||
|
try testing.expectEqualStrings("=hello", binding.action.text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// For Ghostty 1.2+ we changed our key names to match the W3C and removed
|
// For Ghostty 1.2+ we changed our key names to match the W3C and removed
|
||||||
// `physical:`. This tests the backwards compatibility with the old format.
|
// `physical:`. This tests the backwards compatibility with the old format.
|
||||||
// Note that our backwards compatibility isn't 100% perfect since triggers
|
// Note that our backwards compatibility isn't 100% perfect since triggers
|
||||||
|
|
@ -3166,3 +3223,18 @@ test "parse: set_font_size" {
|
||||||
try testing.expectEqual(13.5, binding.action.set_font_size);
|
try testing.expectEqual(13.5, binding.action.set_font_size);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test "action: format" {
|
||||||
|
const testing = std.testing;
|
||||||
|
const alloc = testing.allocator;
|
||||||
|
|
||||||
|
const a: Action = .{ .text = "👻" };
|
||||||
|
|
||||||
|
var buf: std.ArrayListUnmanaged(u8) = .empty;
|
||||||
|
defer buf.deinit(alloc);
|
||||||
|
|
||||||
|
const writer = buf.writer(alloc);
|
||||||
|
try a.format("", .{}, writer);
|
||||||
|
|
||||||
|
try testing.expectEqualStrings("text:\\xf0\\x9f\\x91\\xbb", buf.items);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -346,6 +346,10 @@ fn legacy(
|
||||||
// ever be a multi-codepoint sequence that triggers this.
|
// ever be a multi-codepoint sequence that triggers this.
|
||||||
if (it.nextCodepoint() != null) break :modify_other;
|
if (it.nextCodepoint() != null) break :modify_other;
|
||||||
|
|
||||||
|
// The mods we encode for this are just the binding mods (shift, ctrl,
|
||||||
|
// super, alt).
|
||||||
|
const mods = self.event.mods.binding();
|
||||||
|
|
||||||
// This copies xterm's `ModifyOtherKeys` function that returns
|
// This copies xterm's `ModifyOtherKeys` function that returns
|
||||||
// whether modify other keys should be encoded for the given
|
// whether modify other keys should be encoded for the given
|
||||||
// input.
|
// input.
|
||||||
|
|
@ -355,7 +359,7 @@ fn legacy(
|
||||||
break :should_modify true;
|
break :should_modify true;
|
||||||
|
|
||||||
// If we have anything other than shift pressed, encode.
|
// If we have anything other than shift pressed, encode.
|
||||||
var mods_no_shift = binding_mods;
|
var mods_no_shift = mods;
|
||||||
mods_no_shift.shift = false;
|
mods_no_shift.shift = false;
|
||||||
if (!mods_no_shift.empty()) break :should_modify true;
|
if (!mods_no_shift.empty()) break :should_modify true;
|
||||||
|
|
||||||
|
|
@ -370,7 +374,7 @@ fn legacy(
|
||||||
|
|
||||||
if (should_modify) {
|
if (should_modify) {
|
||||||
for (function_keys.modifiers, 2..) |modset, code| {
|
for (function_keys.modifiers, 2..) |modset, code| {
|
||||||
if (!binding_mods.equal(modset)) continue;
|
if (!mods.equal(modset)) continue;
|
||||||
return try std.fmt.bufPrint(
|
return try std.fmt.bufPrint(
|
||||||
buf,
|
buf,
|
||||||
"\x1B[27;{};{}~",
|
"\x1B[27;{};{}~",
|
||||||
|
|
@ -1984,6 +1988,22 @@ test "legacy: ctrl+shift+char with modify other state 2" {
|
||||||
try testing.expectEqualStrings("\x1b[27;6;72~", actual);
|
try testing.expectEqualStrings("\x1b[27;6;72~", actual);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test "legacy: ctrl+shift+char with modify other state 2 and consumed mods" {
|
||||||
|
var buf: [128]u8 = undefined;
|
||||||
|
var enc: KeyEncoder = .{
|
||||||
|
.event = .{
|
||||||
|
.key = .key_h,
|
||||||
|
.mods = .{ .ctrl = true, .shift = true },
|
||||||
|
.consumed_mods = .{ .shift = true },
|
||||||
|
.utf8 = "H",
|
||||||
|
},
|
||||||
|
.modify_other_keys_state_2 = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const actual = try enc.legacy(&buf);
|
||||||
|
try testing.expectEqualStrings("\x1b[27;6;72~", actual);
|
||||||
|
}
|
||||||
|
|
||||||
test "legacy: fixterm awkward letters" {
|
test "legacy: fixterm awkward letters" {
|
||||||
var buf: [128]u8 = undefined;
|
var buf: [128]u8 = undefined;
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -472,13 +472,18 @@ fn actionCommands(action: Action.Key) []const Command {
|
||||||
.description = "Quit the application.",
|
.description = "Quit the application.",
|
||||||
}},
|
}},
|
||||||
|
|
||||||
|
.text => comptime &.{.{
|
||||||
|
.action = .{ .text = "👻" },
|
||||||
|
.title = "Ghostty",
|
||||||
|
.description = "Put a little Ghostty in your terminal.",
|
||||||
|
}},
|
||||||
|
|
||||||
// No commands because they're parameterized and there
|
// No commands because they're parameterized and there
|
||||||
// aren't obvious values users would use. It is possible that
|
// aren't obvious values users would use. It is possible that
|
||||||
// these may have commands in the future if there are very
|
// these may have commands in the future if there are very
|
||||||
// common values that users tend to use.
|
// common values that users tend to use.
|
||||||
.csi,
|
.csi,
|
||||||
.esc,
|
.esc,
|
||||||
.text,
|
|
||||||
.cursor_key,
|
.cursor_key,
|
||||||
.set_font_size,
|
.set_font_size,
|
||||||
.scroll_page_fractional,
|
.scroll_page_fractional,
|
||||||
|
|
|
||||||
|
|
@ -63,18 +63,42 @@ const Info = extern struct {
|
||||||
pub const String = extern struct {
|
pub const String = extern struct {
|
||||||
ptr: ?[*]const u8,
|
ptr: ?[*]const u8,
|
||||||
len: usize,
|
len: usize,
|
||||||
|
sentinel: bool,
|
||||||
|
|
||||||
pub const empty: String = .{
|
pub const empty: String = .{
|
||||||
.ptr = null,
|
.ptr = null,
|
||||||
.len = 0,
|
.len = 0,
|
||||||
|
.sentinel = false,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn fromSlice(slice: []const u8) String {
|
pub fn fromSlice(slice: anytype) String {
|
||||||
return .{
|
return .{
|
||||||
.ptr = slice.ptr,
|
.ptr = slice.ptr,
|
||||||
.len = slice.len,
|
.len = slice.len,
|
||||||
|
.sentinel = sentinel: {
|
||||||
|
const info = @typeInfo(@TypeOf(slice));
|
||||||
|
switch (info) {
|
||||||
|
.pointer => |p| {
|
||||||
|
if (p.size != .slice) @compileError("only slices supported");
|
||||||
|
if (p.child != u8) @compileError("only u8 slices supported");
|
||||||
|
const sentinel_ = p.sentinel();
|
||||||
|
if (sentinel_) |sentinel| if (sentinel != 0) @compileError("only 0 is supported for sentinels");
|
||||||
|
break :sentinel sentinel_ != null;
|
||||||
|
},
|
||||||
|
else => @compileError("only []const u8 and [:0]const u8"),
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn deinit(self: *const String) void {
|
||||||
|
const ptr = self.ptr orelse return;
|
||||||
|
if (self.sentinel) {
|
||||||
|
state.alloc.free(ptr[0..self.len :0]);
|
||||||
|
} else {
|
||||||
|
state.alloc.free(ptr[0..self.len]);
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Initialize ghostty global state.
|
/// Initialize ghostty global state.
|
||||||
|
|
@ -129,5 +153,45 @@ pub export fn ghostty_translate(msgid: [*:0]const u8) [*:0]const u8 {
|
||||||
|
|
||||||
/// Free a string allocated by Ghostty.
|
/// Free a string allocated by Ghostty.
|
||||||
pub export fn ghostty_string_free(str: String) void {
|
pub export fn ghostty_string_free(str: String) void {
|
||||||
state.alloc.free(str.ptr.?[0..str.len]);
|
str.deinit();
|
||||||
|
}
|
||||||
|
|
||||||
|
test "ghostty_string_s empty string" {
|
||||||
|
const testing = std.testing;
|
||||||
|
const empty_string = String.empty;
|
||||||
|
defer empty_string.deinit();
|
||||||
|
|
||||||
|
try testing.expect(empty_string.len == 0);
|
||||||
|
try testing.expect(empty_string.sentinel == false);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "ghostty_string_s c string" {
|
||||||
|
const testing = std.testing;
|
||||||
|
state.alloc = testing.allocator;
|
||||||
|
|
||||||
|
const slice: [:0]const u8 = "hello";
|
||||||
|
const allocated_slice = try testing.allocator.dupeZ(u8, slice);
|
||||||
|
const c_null_string = String.fromSlice(allocated_slice);
|
||||||
|
defer c_null_string.deinit();
|
||||||
|
|
||||||
|
try testing.expect(allocated_slice[5] == 0);
|
||||||
|
try testing.expect(@TypeOf(slice) == [:0]const u8);
|
||||||
|
try testing.expect(@TypeOf(allocated_slice) == [:0]u8);
|
||||||
|
try testing.expect(c_null_string.len == 5);
|
||||||
|
try testing.expect(c_null_string.sentinel == true);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "ghostty_string_s zig string" {
|
||||||
|
const testing = std.testing;
|
||||||
|
state.alloc = testing.allocator;
|
||||||
|
|
||||||
|
const slice: []const u8 = "hello";
|
||||||
|
const allocated_slice = try testing.allocator.dupe(u8, slice);
|
||||||
|
const zig_string = String.fromSlice(allocated_slice);
|
||||||
|
defer zig_string.deinit();
|
||||||
|
|
||||||
|
try testing.expect(@TypeOf(slice) == []const u8);
|
||||||
|
try testing.expect(@TypeOf(allocated_slice) == []u8);
|
||||||
|
try testing.expect(zig_string.len == 5);
|
||||||
|
try testing.expect(zig_string.sentinel == false);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,21 @@ pub fn getenv(alloc: Allocator, key: []const u8) Error!?GetEnvResult {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Gets the value of an environment variable. Returns null if not found or the
|
||||||
|
/// value is empty. This will allocate on Windows but not on other platforms.
|
||||||
|
/// The returned value should have deinit called to do the proper cleanup no
|
||||||
|
/// matter what platform you are on.
|
||||||
|
pub fn getenvNotEmpty(alloc: Allocator, key: []const u8) !?GetEnvResult {
|
||||||
|
const result_ = try getenv(alloc, key);
|
||||||
|
if (result_) |result| {
|
||||||
|
if (result.value.len == 0) {
|
||||||
|
result.deinit(alloc);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result_;
|
||||||
|
}
|
||||||
|
|
||||||
pub fn setenv(key: [:0]const u8, value: [:0]const u8) c_int {
|
pub fn setenv(key: [:0]const u8, value: [:0]const u8) c_int {
|
||||||
return switch (builtin.os.tag) {
|
return switch (builtin.os.tag) {
|
||||||
.windows => c._putenv_s(key.ptr, value.ptr),
|
.windows => c._putenv_s(key.ptr, value.ptr),
|
||||||
|
|
|
||||||
|
|
@ -46,10 +46,6 @@ fn isValidMacAddress(mac_address: []const u8) bool {
|
||||||
/// correctly.
|
/// correctly.
|
||||||
pub fn parseUrl(url: []const u8) UrlParsingError!std.Uri {
|
pub fn parseUrl(url: []const u8) UrlParsingError!std.Uri {
|
||||||
return std.Uri.parse(url) catch |e| {
|
return std.Uri.parse(url) catch |e| {
|
||||||
// The mac-address-as-hostname issue is specific to macOS so we just return an error if we
|
|
||||||
// hit it on other platforms.
|
|
||||||
if (comptime builtin.os.tag != .macos) return e;
|
|
||||||
|
|
||||||
// It's possible this is a mac address on macOS where the last 2 characters in the
|
// It's possible this is a mac address on macOS where the last 2 characters in the
|
||||||
// address are non-digits, e.g. 'ff', and thus an invalid port.
|
// address are non-digits, e.g. 'ff', and thus an invalid port.
|
||||||
//
|
//
|
||||||
|
|
@ -148,6 +144,91 @@ pub const LocalHostnameValidationError = error{
|
||||||
Unexpected,
|
Unexpected,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// Validates a hostname according to [RFC 1123](https://www.rfc-editor.org/rfc/rfc1123)
|
||||||
|
///
|
||||||
|
/// std.net.isValidHostname is (currently) too generous. It considers strings like
|
||||||
|
/// ".example.com", "exa..mple.com", and "-example.com" to be valid hostnames, which
|
||||||
|
/// is incorrect.
|
||||||
|
pub fn isValid(hostname: []const u8) bool {
|
||||||
|
if (hostname.len == 0) return false;
|
||||||
|
if (hostname[0] == '.') return false;
|
||||||
|
|
||||||
|
// Ignore trailing dot (FQDN). It doesn't count toward our length.
|
||||||
|
const end = if (hostname[hostname.len - 1] == '.') end: {
|
||||||
|
if (hostname.len == 1) return false;
|
||||||
|
break :end hostname.len - 1;
|
||||||
|
} else hostname.len;
|
||||||
|
|
||||||
|
if (end > 253) return false;
|
||||||
|
|
||||||
|
// Hostnames are divided into dot-separated "labels", which:
|
||||||
|
//
|
||||||
|
// - Start with a letter or digit
|
||||||
|
// - Can contain letters, digits, or hyphens
|
||||||
|
// - Must end with a letter or digit
|
||||||
|
// - Have a minimum of 1 character and a maximum of 63
|
||||||
|
var label_start: usize = 0;
|
||||||
|
var label_len: usize = 0;
|
||||||
|
for (hostname[0..end], 0..) |c, i| {
|
||||||
|
switch (c) {
|
||||||
|
'.' => {
|
||||||
|
if (label_len == 0 or label_len > 63) return false;
|
||||||
|
if (!std.ascii.isAlphanumeric(hostname[label_start])) return false;
|
||||||
|
if (!std.ascii.isAlphanumeric(hostname[i - 1])) return false;
|
||||||
|
|
||||||
|
label_start = i + 1;
|
||||||
|
label_len = 0;
|
||||||
|
},
|
||||||
|
'-' => {
|
||||||
|
label_len += 1;
|
||||||
|
},
|
||||||
|
else => {
|
||||||
|
if (!std.ascii.isAlphanumeric(c)) return false;
|
||||||
|
label_len += 1;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the final label
|
||||||
|
if (label_len == 0 or label_len > 63) return false;
|
||||||
|
if (!std.ascii.isAlphanumeric(hostname[label_start])) return false;
|
||||||
|
if (!std.ascii.isAlphanumeric(hostname[end - 1])) return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
test isValid {
|
||||||
|
const testing = std.testing;
|
||||||
|
|
||||||
|
// Valid hostnames
|
||||||
|
try testing.expect(isValid("example"));
|
||||||
|
try testing.expect(isValid("example.com"));
|
||||||
|
try testing.expect(isValid("www.example.com"));
|
||||||
|
try testing.expect(isValid("sub.domain.example.com"));
|
||||||
|
try testing.expect(isValid("example.com."));
|
||||||
|
try testing.expect(isValid("host-name.example.com."));
|
||||||
|
try testing.expect(isValid("123.example.com."));
|
||||||
|
try testing.expect(isValid("a-b.com"));
|
||||||
|
try testing.expect(isValid("a.b.c.d.e.f.g"));
|
||||||
|
try testing.expect(isValid("127.0.0.1")); // Also a valid hostname
|
||||||
|
try testing.expect(isValid("a" ** 63 ++ ".com")); // Label exactly 63 chars (valid)
|
||||||
|
try testing.expect(isValid("a." ** 126 ++ "a")); // Total length 253 (valid)
|
||||||
|
|
||||||
|
// Invalid hostnames
|
||||||
|
try testing.expect(!isValid(""));
|
||||||
|
try testing.expect(!isValid(".example.com"));
|
||||||
|
try testing.expect(!isValid("example.com.."));
|
||||||
|
try testing.expect(!isValid("host..domain"));
|
||||||
|
try testing.expect(!isValid("-hostname"));
|
||||||
|
try testing.expect(!isValid("hostname-"));
|
||||||
|
try testing.expect(!isValid("a.-.b"));
|
||||||
|
try testing.expect(!isValid("host_name.com"));
|
||||||
|
try testing.expect(!isValid("."));
|
||||||
|
try testing.expect(!isValid(".."));
|
||||||
|
try testing.expect(!isValid("a" ** 64 ++ ".com")); // Label length 64 (too long)
|
||||||
|
try testing.expect(!isValid("a." ** 126 ++ "ab")); // Total length 254 (too long)
|
||||||
|
}
|
||||||
|
|
||||||
/// Checks if a hostname is local to the current machine. This matches
|
/// Checks if a hostname is local to the current machine. This matches
|
||||||
/// both "localhost" and the current hostname of the machine (as returned
|
/// both "localhost" and the current hostname of the machine (as returned
|
||||||
/// by `gethostname`).
|
/// by `gethostname`).
|
||||||
|
|
@ -197,7 +278,6 @@ test parseUrl {
|
||||||
try std.testing.expect(uri.port == 12);
|
try std.testing.expect(uri.port == 12);
|
||||||
|
|
||||||
// Alphabetical mac addresses.
|
// Alphabetical mac addresses.
|
||||||
|
|
||||||
uri = try parseUrl("file://ab:cd:ef:ab:cd:ef/home/test/");
|
uri = try parseUrl("file://ab:cd:ef:ab:cd:ef/home/test/");
|
||||||
|
|
||||||
try std.testing.expectEqualStrings("file", uri.scheme);
|
try std.testing.expectEqualStrings("file", uri.scheme);
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,8 @@ pub const locales = [_][:0]const u8{
|
||||||
"ga_IE.UTF-8",
|
"ga_IE.UTF-8",
|
||||||
"hu_HU.UTF-8",
|
"hu_HU.UTF-8",
|
||||||
"he_IL.UTF-8",
|
"he_IL.UTF-8",
|
||||||
|
"zh_TW.UTF-8",
|
||||||
|
"hr_HR.UTF-8",
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Set for faster membership lookup of locales.
|
/// Set for faster membership lookup of locales.
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,11 @@ fn setLangFromCocoa() void {
|
||||||
const lang = locale.getProperty(objc.Object, "languageCode");
|
const lang = locale.getProperty(objc.Object, "languageCode");
|
||||||
const country = locale.getProperty(objc.Object, "countryCode");
|
const country = locale.getProperty(objc.Object, "countryCode");
|
||||||
|
|
||||||
|
if (lang.value == null or country.value == null) {
|
||||||
|
log.warn("languageCode or countryCode not found. Locale may be incorrect.", .{});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Get our UTF8 string values
|
// Get our UTF8 string values
|
||||||
const c_lang = lang.getProperty([*:0]const u8, "UTF8String");
|
const c_lang = lang.getProperty([*:0]const u8, "UTF8String");
|
||||||
const c_country = country.getProperty([*:0]const u8, "UTF8String");
|
const c_country = country.getProperty([*:0]const u8, "UTF8String");
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,10 @@ pub fn ShellEscapeWriter(comptime T: type) type {
|
||||||
|
|
||||||
const Writer = std.io.Writer(*ShellEscapeWriter(T), error{Error}, write);
|
const Writer = std.io.Writer(*ShellEscapeWriter(T), error{Error}, write);
|
||||||
|
|
||||||
|
pub fn init(child_writer: T) ShellEscapeWriter(T) {
|
||||||
|
return .{ .child_writer = child_writer };
|
||||||
|
}
|
||||||
|
|
||||||
pub fn writer(self: *ShellEscapeWriter(T)) Writer {
|
pub fn writer(self: *ShellEscapeWriter(T)) Writer {
|
||||||
return .{ .context = self };
|
return .{ .context = self };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
154
src/os/xdg.zig
154
src/os/xdg.zig
|
|
@ -7,6 +7,7 @@ const assert = std.debug.assert;
|
||||||
const Allocator = std.mem.Allocator;
|
const Allocator = std.mem.Allocator;
|
||||||
const posix = std.posix;
|
const posix = std.posix;
|
||||||
const homedir = @import("homedir.zig");
|
const homedir = @import("homedir.zig");
|
||||||
|
const env_os = @import("env.zig");
|
||||||
|
|
||||||
pub const Options = struct {
|
pub const Options = struct {
|
||||||
/// Subdirectories to join to the base. This avoids extra allocations
|
/// Subdirectories to join to the base. This avoids extra allocations
|
||||||
|
|
@ -70,36 +71,22 @@ fn dir(
|
||||||
// First check the env var. On Windows we have to allocate so this tracks
|
// First check the env var. On Windows we have to allocate so this tracks
|
||||||
// both whether we have the env var and whether we own it.
|
// both whether we have the env var and whether we own it.
|
||||||
// on Windows we treat `LOCALAPPDATA` as a fallback for `XDG_CONFIG_HOME`
|
// on Windows we treat `LOCALAPPDATA` as a fallback for `XDG_CONFIG_HOME`
|
||||||
const env_, const owned = switch (builtin.os.tag) {
|
const env_ = try env_os.getenvNotEmpty(alloc, internal_opts.env) orelse switch (builtin.os.tag) {
|
||||||
else => .{ posix.getenv(internal_opts.env), false },
|
else => null,
|
||||||
.windows => windows: {
|
.windows => try env_os.getenvNotEmpty(alloc, internal_opts.windows_env),
|
||||||
if (std.process.getEnvVarOwned(alloc, internal_opts.env)) |env| {
|
|
||||||
break :windows .{ env, true };
|
|
||||||
} else |err| switch (err) {
|
|
||||||
error.EnvironmentVariableNotFound => {
|
|
||||||
if (std.process.getEnvVarOwned(alloc, internal_opts.windows_env)) |env| {
|
|
||||||
break :windows .{ env, true };
|
|
||||||
} else |err2| switch (err2) {
|
|
||||||
error.EnvironmentVariableNotFound => break :windows .{ null, false },
|
|
||||||
else => return err,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
else => return err,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
defer if (owned) if (env_) |v| alloc.free(v);
|
defer if (env_) |env| env.deinit(alloc);
|
||||||
|
|
||||||
if (env_) |env| {
|
if (env_) |env| {
|
||||||
// If we have a subdir, then we use the env as-is to avoid a copy.
|
// If we have a subdir, then we use the env as-is to avoid a copy.
|
||||||
if (opts.subdir) |subdir| {
|
if (opts.subdir) |subdir| {
|
||||||
return try std.fs.path.join(alloc, &[_][]const u8{
|
return try std.fs.path.join(alloc, &[_][]const u8{
|
||||||
env,
|
env.value,
|
||||||
subdir,
|
subdir,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return try alloc.dupe(u8, env);
|
return try alloc.dupe(u8, env.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get our home dir
|
// Get our home dir
|
||||||
|
|
@ -169,6 +156,133 @@ test "cache directory paths" {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test "fallback when xdg env empty" {
|
||||||
|
if (builtin.os.tag == .windows) return error.SkipZigTest;
|
||||||
|
|
||||||
|
const alloc = std.testing.allocator;
|
||||||
|
|
||||||
|
const saved_home = home: {
|
||||||
|
const home = std.posix.getenv("HOME") orelse break :home null;
|
||||||
|
break :home try alloc.dupeZ(u8, home);
|
||||||
|
};
|
||||||
|
defer env: {
|
||||||
|
const home = saved_home orelse {
|
||||||
|
_ = env_os.unsetenv("HOME");
|
||||||
|
break :env;
|
||||||
|
};
|
||||||
|
_ = env_os.setenv("HOME", home);
|
||||||
|
std.testing.allocator.free(home);
|
||||||
|
}
|
||||||
|
const temp_home = "/tmp/ghostty-test-home";
|
||||||
|
_ = env_os.setenv("HOME", temp_home);
|
||||||
|
|
||||||
|
const DirCase = struct {
|
||||||
|
name: [:0]const u8,
|
||||||
|
func: fn (Allocator, Options) anyerror![]u8,
|
||||||
|
default_subdir: []const u8,
|
||||||
|
};
|
||||||
|
|
||||||
|
const cases = [_]DirCase{
|
||||||
|
.{ .name = "XDG_CONFIG_HOME", .func = config, .default_subdir = ".config" },
|
||||||
|
.{ .name = "XDG_CACHE_HOME", .func = cache, .default_subdir = ".cache" },
|
||||||
|
.{ .name = "XDG_STATE_HOME", .func = state, .default_subdir = ".local/state" },
|
||||||
|
};
|
||||||
|
|
||||||
|
inline for (cases) |case| {
|
||||||
|
// Save and restore each environment variable
|
||||||
|
const saved_env = blk: {
|
||||||
|
const value = std.posix.getenv(case.name) orelse break :blk null;
|
||||||
|
break :blk try alloc.dupeZ(u8, value);
|
||||||
|
};
|
||||||
|
defer env: {
|
||||||
|
const value = saved_env orelse {
|
||||||
|
_ = env_os.unsetenv(case.name);
|
||||||
|
break :env;
|
||||||
|
};
|
||||||
|
_ = env_os.setenv(case.name, value);
|
||||||
|
alloc.free(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const expected = try std.fs.path.join(alloc, &[_][]const u8{
|
||||||
|
temp_home,
|
||||||
|
case.default_subdir,
|
||||||
|
});
|
||||||
|
defer alloc.free(expected);
|
||||||
|
|
||||||
|
// Test with empty string - should fallback to home
|
||||||
|
_ = env_os.setenv(case.name, "");
|
||||||
|
const actual = try case.func(alloc, .{});
|
||||||
|
defer alloc.free(actual);
|
||||||
|
|
||||||
|
try std.testing.expectEqualStrings(expected, actual);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test "fallback when xdg env empty and subdir" {
|
||||||
|
if (builtin.os.tag == .windows) return error.SkipZigTest;
|
||||||
|
|
||||||
|
const env = @import("env.zig");
|
||||||
|
const alloc = std.testing.allocator;
|
||||||
|
|
||||||
|
const saved_home = home: {
|
||||||
|
const home = std.posix.getenv("HOME") orelse break :home null;
|
||||||
|
break :home try alloc.dupeZ(u8, home);
|
||||||
|
};
|
||||||
|
defer env: {
|
||||||
|
const home = saved_home orelse {
|
||||||
|
_ = env.unsetenv("HOME");
|
||||||
|
break :env;
|
||||||
|
};
|
||||||
|
_ = env.setenv("HOME", home);
|
||||||
|
std.testing.allocator.free(home);
|
||||||
|
}
|
||||||
|
|
||||||
|
const temp_home = "/tmp/ghostty-test-home";
|
||||||
|
_ = env.setenv("HOME", temp_home);
|
||||||
|
|
||||||
|
const DirCase = struct {
|
||||||
|
name: [:0]const u8,
|
||||||
|
func: fn (Allocator, Options) anyerror![]u8,
|
||||||
|
default_subdir: []const u8,
|
||||||
|
};
|
||||||
|
|
||||||
|
const cases = [_]DirCase{
|
||||||
|
.{ .name = "XDG_CONFIG_HOME", .func = config, .default_subdir = ".config" },
|
||||||
|
.{ .name = "XDG_CACHE_HOME", .func = cache, .default_subdir = ".cache" },
|
||||||
|
.{ .name = "XDG_STATE_HOME", .func = state, .default_subdir = ".local/state" },
|
||||||
|
};
|
||||||
|
|
||||||
|
inline for (cases) |case| {
|
||||||
|
// Save and restore each environment variable
|
||||||
|
const saved_env = blk: {
|
||||||
|
const value = std.posix.getenv(case.name) orelse break :blk null;
|
||||||
|
break :blk try alloc.dupeZ(u8, value);
|
||||||
|
};
|
||||||
|
defer env: {
|
||||||
|
const value = saved_env orelse {
|
||||||
|
_ = env.unsetenv(case.name);
|
||||||
|
break :env;
|
||||||
|
};
|
||||||
|
_ = env.setenv(case.name, value);
|
||||||
|
alloc.free(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const expected = try std.fs.path.join(alloc, &[_][]const u8{
|
||||||
|
temp_home,
|
||||||
|
case.default_subdir,
|
||||||
|
"ghostty",
|
||||||
|
});
|
||||||
|
defer alloc.free(expected);
|
||||||
|
|
||||||
|
// Test with empty string - should fallback to home
|
||||||
|
_ = env.setenv(case.name, "");
|
||||||
|
const actual = try case.func(alloc, .{ .subdir = "ghostty" });
|
||||||
|
defer alloc.free(actual);
|
||||||
|
|
||||||
|
try std.testing.expectEqualStrings(expected, actual);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
test parseTerminalExec {
|
test parseTerminalExec {
|
||||||
const testing = std.testing;
|
const testing = std.testing;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ pub const RenderPass = @import("metal/RenderPass.zig");
|
||||||
pub const Pipeline = @import("metal/Pipeline.zig");
|
pub const Pipeline = @import("metal/Pipeline.zig");
|
||||||
const bufferpkg = @import("metal/buffer.zig");
|
const bufferpkg = @import("metal/buffer.zig");
|
||||||
pub const Buffer = bufferpkg.Buffer;
|
pub const Buffer = bufferpkg.Buffer;
|
||||||
|
pub const Sampler = @import("metal/Sampler.zig");
|
||||||
pub const Texture = @import("metal/Texture.zig");
|
pub const Texture = @import("metal/Texture.zig");
|
||||||
pub const shaders = @import("metal/shaders.zig");
|
pub const shaders = @import("metal/shaders.zig");
|
||||||
|
|
||||||
|
|
@ -273,6 +274,27 @@ pub inline fn textureOptions(self: Metal) Texture.Options {
|
||||||
.cpu_cache_mode = .write_combined,
|
.cpu_cache_mode = .write_combined,
|
||||||
.storage_mode = self.default_storage_mode,
|
.storage_mode = self.default_storage_mode,
|
||||||
},
|
},
|
||||||
|
.usage = .{
|
||||||
|
// textureOptions is currently only used for custom shaders,
|
||||||
|
// which require both the shader read (for when multiple shaders
|
||||||
|
// are chained) and render target (for the final output) usage.
|
||||||
|
// Disabling either of these will lead to metal validation
|
||||||
|
// errors in Xcode.
|
||||||
|
.shader_read = true,
|
||||||
|
.render_target = true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub inline fn samplerOptions(self: Metal) Sampler.Options {
|
||||||
|
return .{
|
||||||
|
.device = self.device,
|
||||||
|
|
||||||
|
// These parameters match Shadertoy behaviors.
|
||||||
|
.min_filter = .linear,
|
||||||
|
.mag_filter = .linear,
|
||||||
|
.s_address_mode = .clamp_to_edge,
|
||||||
|
.t_address_mode = .clamp_to_edge,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -311,6 +333,10 @@ pub inline fn imageTextureOptions(
|
||||||
.cpu_cache_mode = .write_combined,
|
.cpu_cache_mode = .write_combined,
|
||||||
.storage_mode = self.default_storage_mode,
|
.storage_mode = self.default_storage_mode,
|
||||||
},
|
},
|
||||||
|
.usage = .{
|
||||||
|
// We only need to read from this texture from a shader.
|
||||||
|
.shader_read = true,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -334,6 +360,10 @@ pub fn initAtlasTexture(
|
||||||
.cpu_cache_mode = .write_combined,
|
.cpu_cache_mode = .write_combined,
|
||||||
.storage_mode = self.default_storage_mode,
|
.storage_mode = self.default_storage_mode,
|
||||||
},
|
},
|
||||||
|
.usage = .{
|
||||||
|
// We only need to read from this texture from a shader.
|
||||||
|
.shader_read = true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
atlas.size,
|
atlas.size,
|
||||||
atlas.size,
|
atlas.size,
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ pub const RenderPass = @import("opengl/RenderPass.zig");
|
||||||
pub const Pipeline = @import("opengl/Pipeline.zig");
|
pub const Pipeline = @import("opengl/Pipeline.zig");
|
||||||
const bufferpkg = @import("opengl/buffer.zig");
|
const bufferpkg = @import("opengl/buffer.zig");
|
||||||
pub const Buffer = bufferpkg.Buffer;
|
pub const Buffer = bufferpkg.Buffer;
|
||||||
|
pub const Sampler = @import("opengl/Sampler.zig");
|
||||||
pub const Texture = @import("opengl/Texture.zig");
|
pub const Texture = @import("opengl/Texture.zig");
|
||||||
pub const shaders = @import("opengl/shaders.zig");
|
pub const shaders = @import("opengl/shaders.zig");
|
||||||
|
|
||||||
|
|
@ -364,6 +365,17 @@ pub inline fn textureOptions(self: OpenGL) Texture.Options {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the options to use when constructing samplers.
|
||||||
|
pub inline fn samplerOptions(self: OpenGL) Sampler.Options {
|
||||||
|
_ = self;
|
||||||
|
return .{
|
||||||
|
.min_filter = .linear,
|
||||||
|
.mag_filter = .linear,
|
||||||
|
.wrap_s = .clamp_to_edge,
|
||||||
|
.wrap_t = .clamp_to_edge,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/// Pixel format for image texture options.
|
/// Pixel format for image texture options.
|
||||||
pub const ImageTextureFormat = enum {
|
pub const ImageTextureFormat = enum {
|
||||||
/// 1 byte per pixel grayscale.
|
/// 1 byte per pixel grayscale.
|
||||||
|
|
|
||||||
|
|
@ -236,7 +236,7 @@ pub fn isCovering(cp: u21) bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns true of the codepoint is a "symbol-like" character, which
|
/// Returns true of the codepoint is a "symbol-like" character, which
|
||||||
/// for now we define as anything in a private use area and anything
|
/// for now we define as anything in a private use area, and anything
|
||||||
/// in several unicode blocks:
|
/// in several unicode blocks:
|
||||||
/// - Dingbats
|
/// - Dingbats
|
||||||
/// - Emoticons
|
/// - Emoticons
|
||||||
|
|
@ -274,9 +274,9 @@ pub fn constraintWidth(cell_pin: terminal.Pin) u2 {
|
||||||
|
|
||||||
// If we have a previous cell and it was a symbol then we need
|
// If we have a previous cell and it was a symbol then we need
|
||||||
// to also constrain. This is so that multiple PUA glyphs align.
|
// to also constrain. This is so that multiple PUA glyphs align.
|
||||||
// As an exception, we ignore powerline glyphs since they are
|
// This does not apply if the previous symbol is a graphics
|
||||||
// used for box drawing and we consider them whitespace.
|
// element such as a block element or Powerline glyph.
|
||||||
if (cell_pin.x > 0) prev: {
|
if (cell_pin.x > 0) {
|
||||||
const prev_cp = prev_cp: {
|
const prev_cp = prev_cp: {
|
||||||
var copy = cell_pin;
|
var copy = cell_pin;
|
||||||
copy.x -= 1;
|
copy.x -= 1;
|
||||||
|
|
@ -284,10 +284,7 @@ pub fn constraintWidth(cell_pin: terminal.Pin) u2 {
|
||||||
break :prev_cp prev_cell.codepoint();
|
break :prev_cp prev_cell.codepoint();
|
||||||
};
|
};
|
||||||
|
|
||||||
// We consider powerline glyphs whitespace.
|
if (isSymbol(prev_cp) and !isGraphicsElement(prev_cp)) {
|
||||||
if (isPowerline(prev_cp)) break :prev;
|
|
||||||
|
|
||||||
if (isSymbol(prev_cp)) {
|
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -300,10 +297,7 @@ pub fn constraintWidth(cell_pin: terminal.Pin) u2 {
|
||||||
const next_cell = copy.rowAndCell().cell;
|
const next_cell = copy.rowAndCell().cell;
|
||||||
break :next_cp next_cell.codepoint();
|
break :next_cp next_cell.codepoint();
|
||||||
};
|
};
|
||||||
if (next_cp == 0 or
|
if (next_cp == 0 or isSpace(next_cp)) {
|
||||||
isSpace(next_cp) or
|
|
||||||
isPowerline(next_cp))
|
|
||||||
{
|
|
||||||
return 2;
|
return 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -311,10 +305,10 @@ pub fn constraintWidth(cell_pin: terminal.Pin) u2 {
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether min contrast should be disabled for a given glyph.
|
/// Whether min contrast should be disabled for a given glyph. True
|
||||||
|
/// for graphics elements such as blocks and Powerline glyphs.
|
||||||
pub fn noMinContrast(cp: u21) bool {
|
pub fn noMinContrast(cp: u21) bool {
|
||||||
// TODO: We should disable for all box drawing type characters.
|
return isGraphicsElement(cp);
|
||||||
return isPowerline(cp);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Some general spaces, others intentionally kept
|
// Some general spaces, others intentionally kept
|
||||||
|
|
@ -328,10 +322,42 @@ fn isSpace(char: u21) bool {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns true if the codepoint is used for terminal graphics, such
|
||||||
|
/// as box drawing characters, block elements, and Powerline glyphs.
|
||||||
|
fn isGraphicsElement(char: u21) bool {
|
||||||
|
return isBoxDrawing(char) or isBlockElement(char) or isLegacyComputing(char) or isPowerline(char);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns true if the codepoint is a box drawing character.
|
||||||
|
fn isBoxDrawing(char: u21) bool {
|
||||||
|
return switch (char) {
|
||||||
|
0x2500...0x257F => true,
|
||||||
|
else => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns true if the codepoint is a block element.
|
||||||
|
fn isBlockElement(char: u21) bool {
|
||||||
|
return switch (char) {
|
||||||
|
0x2580...0x259F => true,
|
||||||
|
else => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns true if the codepoint is in a Symbols for Legacy
|
||||||
|
// Computing block, including supplements.
|
||||||
|
fn isLegacyComputing(char: u21) bool {
|
||||||
|
return switch (char) {
|
||||||
|
0x1FB00...0x1FBFF => true,
|
||||||
|
0x1CC00...0x1CEBF => true, // Supplement introduced in Unicode 16.0
|
||||||
|
else => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Returns true if the codepoint is a part of the Powerline range.
|
// Returns true if the codepoint is a part of the Powerline range.
|
||||||
fn isPowerline(char: u21) bool {
|
fn isPowerline(char: u21) bool {
|
||||||
return switch (char) {
|
return switch (char) {
|
||||||
0xE0B0...0xE0C8, 0xE0CA, 0xE0CC...0xE0D2, 0xE0D4 => true,
|
0xE0B0...0xE0D7 => true,
|
||||||
else => false,
|
else => false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -492,3 +518,113 @@ test "Contents with zero-sized screen" {
|
||||||
c.setCursor(null, null);
|
c.setCursor(null, null);
|
||||||
try testing.expect(c.getCursorGlyph() == null);
|
try testing.expect(c.getCursorGlyph() == null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test "Cell constraint widths" {
|
||||||
|
const testing = std.testing;
|
||||||
|
const alloc = testing.allocator;
|
||||||
|
|
||||||
|
var s = try terminal.Screen.init(alloc, 4, 1, 0);
|
||||||
|
defer s.deinit();
|
||||||
|
|
||||||
|
// for each case, the numbers in the comment denote expected
|
||||||
|
// constraint widths for the symbol-containing cells
|
||||||
|
|
||||||
|
// symbol->nothing: 2
|
||||||
|
{
|
||||||
|
try s.testWriteString("");
|
||||||
|
const p0 = s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?;
|
||||||
|
try testing.expectEqual(2, constraintWidth(p0));
|
||||||
|
s.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
// symbol->character: 1
|
||||||
|
{
|
||||||
|
try s.testWriteString("z");
|
||||||
|
const p0 = s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?;
|
||||||
|
try testing.expectEqual(1, constraintWidth(p0));
|
||||||
|
s.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
// symbol->space: 2
|
||||||
|
{
|
||||||
|
try s.testWriteString(" z");
|
||||||
|
const p0 = s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?;
|
||||||
|
try testing.expectEqual(2, constraintWidth(p0));
|
||||||
|
s.reset();
|
||||||
|
}
|
||||||
|
// symbol->no-break space: 1
|
||||||
|
{
|
||||||
|
try s.testWriteString("\u{00a0}z");
|
||||||
|
const p0 = s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?;
|
||||||
|
try testing.expectEqual(1, constraintWidth(p0));
|
||||||
|
s.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
// symbol->end of row: 1
|
||||||
|
{
|
||||||
|
try s.testWriteString(" ");
|
||||||
|
const p3 = s.pages.pin(.{ .screen = .{ .x = 3, .y = 0 } }).?;
|
||||||
|
try testing.expectEqual(1, constraintWidth(p3));
|
||||||
|
s.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
// character->symbol: 2
|
||||||
|
{
|
||||||
|
try s.testWriteString("z");
|
||||||
|
const p1 = s.pages.pin(.{ .screen = .{ .x = 1, .y = 0 } }).?;
|
||||||
|
try testing.expectEqual(2, constraintWidth(p1));
|
||||||
|
s.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
// symbol->symbol: 1,1
|
||||||
|
{
|
||||||
|
try s.testWriteString("");
|
||||||
|
const p0 = s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?;
|
||||||
|
const p1 = s.pages.pin(.{ .screen = .{ .x = 1, .y = 0 } }).?;
|
||||||
|
try testing.expectEqual(1, constraintWidth(p0));
|
||||||
|
try testing.expectEqual(1, constraintWidth(p1));
|
||||||
|
s.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
// symbol->space->symbol: 2,2
|
||||||
|
{
|
||||||
|
try s.testWriteString(" ");
|
||||||
|
const p0 = s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?;
|
||||||
|
const p2 = s.pages.pin(.{ .screen = .{ .x = 2, .y = 0 } }).?;
|
||||||
|
try testing.expectEqual(2, constraintWidth(p0));
|
||||||
|
try testing.expectEqual(2, constraintWidth(p2));
|
||||||
|
s.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
// symbol->powerline: 1 (dedicated test because powerline is special-cased in cellpkg)
|
||||||
|
{
|
||||||
|
try s.testWriteString("");
|
||||||
|
const p0 = s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?;
|
||||||
|
try testing.expectEqual(1, constraintWidth(p0));
|
||||||
|
s.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
// powerline->symbol: 2 (dedicated test because powerline is special-cased in cellpkg)
|
||||||
|
{
|
||||||
|
try s.testWriteString("");
|
||||||
|
const p1 = s.pages.pin(.{ .screen = .{ .x = 1, .y = 0 } }).?;
|
||||||
|
try testing.expectEqual(2, constraintWidth(p1));
|
||||||
|
s.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
// powerline->nothing: 2 (dedicated test because powerline is special-cased in cellpkg)
|
||||||
|
{
|
||||||
|
try s.testWriteString("");
|
||||||
|
const p0 = s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?;
|
||||||
|
try testing.expectEqual(2, constraintWidth(p0));
|
||||||
|
s.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
// powerline->space: 2 (dedicated test because powerline is special-cased in cellpkg)
|
||||||
|
{
|
||||||
|
try s.testWriteString(" z");
|
||||||
|
const p0 = s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?;
|
||||||
|
try testing.expectEqual(2, constraintWidth(p0));
|
||||||
|
s.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
|
||||||
|
|
||||||
const Target = GraphicsAPI.Target;
|
const Target = GraphicsAPI.Target;
|
||||||
const Buffer = GraphicsAPI.Buffer;
|
const Buffer = GraphicsAPI.Buffer;
|
||||||
|
const Sampler = GraphicsAPI.Sampler;
|
||||||
const Texture = GraphicsAPI.Texture;
|
const Texture = GraphicsAPI.Texture;
|
||||||
const RenderPass = GraphicsAPI.RenderPass;
|
const RenderPass = GraphicsAPI.RenderPass;
|
||||||
|
|
||||||
|
|
@ -428,6 +429,19 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
|
||||||
front_texture: Texture,
|
front_texture: Texture,
|
||||||
back_texture: Texture,
|
back_texture: Texture,
|
||||||
|
|
||||||
|
/// Shadertoy uses a sampler for accessing the various channel
|
||||||
|
/// textures. In Metal, we need to explicitly create these since
|
||||||
|
/// the glslang-to-msl compiler doesn't do it for us (as we
|
||||||
|
/// normally would in hand-written MSL). To keep it clean and
|
||||||
|
/// consistent, we just force all rendering APIs to provide an
|
||||||
|
/// explicit sampler.
|
||||||
|
///
|
||||||
|
/// Samplers are immutable and describe sampling properties so
|
||||||
|
/// we can share the sampler across front/back textures (although
|
||||||
|
/// we only need it for the source texture at a time, we don't
|
||||||
|
/// need to "swap" it).
|
||||||
|
sampler: Sampler,
|
||||||
|
|
||||||
uniforms: UniformBuffer,
|
uniforms: UniformBuffer,
|
||||||
|
|
||||||
const UniformBuffer = Buffer(shadertoy.Uniforms);
|
const UniformBuffer = Buffer(shadertoy.Uniforms);
|
||||||
|
|
@ -459,9 +473,13 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
|
||||||
);
|
);
|
||||||
errdefer back_texture.deinit();
|
errdefer back_texture.deinit();
|
||||||
|
|
||||||
|
const sampler = try Sampler.init(api.samplerOptions());
|
||||||
|
errdefer sampler.deinit();
|
||||||
|
|
||||||
return .{
|
return .{
|
||||||
.front_texture = front_texture,
|
.front_texture = front_texture,
|
||||||
.back_texture = back_texture,
|
.back_texture = back_texture,
|
||||||
|
.sampler = sampler,
|
||||||
.uniforms = uniforms,
|
.uniforms = uniforms,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -469,6 +487,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
|
||||||
pub fn deinit(self: *CustomShaderState) void {
|
pub fn deinit(self: *CustomShaderState) void {
|
||||||
self.front_texture.deinit();
|
self.front_texture.deinit();
|
||||||
self.back_texture.deinit();
|
self.back_texture.deinit();
|
||||||
|
self.sampler.deinit();
|
||||||
self.uniforms.deinit();
|
self.uniforms.deinit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1041,6 +1060,11 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
|
||||||
|
|
||||||
// Update relevant uniforms
|
// Update relevant uniforms
|
||||||
self.updateFontGridUniforms();
|
self.updateFontGridUniforms();
|
||||||
|
|
||||||
|
// Force a full rebuild, because cached rows may still reference
|
||||||
|
// an outdated atlas from the old grid and this can cause garbage
|
||||||
|
// to be rendered.
|
||||||
|
self.cells_viewport = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update uniforms that are based on the font grid.
|
/// Update uniforms that are based on the font grid.
|
||||||
|
|
@ -1509,6 +1533,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
|
||||||
.pipeline = pipeline,
|
.pipeline = pipeline,
|
||||||
.uniforms = state.uniforms.buffer,
|
.uniforms = state.uniforms.buffer,
|
||||||
.textures = &.{state.back_texture},
|
.textures = &.{state.back_texture},
|
||||||
|
.samplers = &.{state.sampler},
|
||||||
.draw = .{
|
.draw = .{
|
||||||
.type = .triangle,
|
.type = .triangle,
|
||||||
.vertex_count = 3,
|
.vertex_count = 3,
|
||||||
|
|
@ -3073,8 +3098,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
|
||||||
// its cell(s), we don't modify the alignment at all.
|
// its cell(s), we don't modify the alignment at all.
|
||||||
.constraint = getConstraint(cp) orelse
|
.constraint = getConstraint(cp) orelse
|
||||||
if (cellpkg.isSymbol(cp)) .{
|
if (cellpkg.isSymbol(cp)) .{
|
||||||
.size_horizontal = .fit,
|
.size = .fit,
|
||||||
.size_vertical = .fit,
|
|
||||||
} else .none,
|
} else .none,
|
||||||
.constraint_width = constraintWidth(cell_pin),
|
.constraint_width = constraintWidth(cell_pin),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ const objc = @import("objc");
|
||||||
|
|
||||||
const mtl = @import("api.zig");
|
const mtl = @import("api.zig");
|
||||||
const Pipeline = @import("Pipeline.zig");
|
const Pipeline = @import("Pipeline.zig");
|
||||||
|
const Sampler = @import("Sampler.zig");
|
||||||
const Texture = @import("Texture.zig");
|
const Texture = @import("Texture.zig");
|
||||||
const Target = @import("Target.zig");
|
const Target = @import("Target.zig");
|
||||||
const Metal = @import("../Metal.zig");
|
const Metal = @import("../Metal.zig");
|
||||||
|
|
@ -41,6 +42,9 @@ pub const Step = struct {
|
||||||
/// MTLBuffer
|
/// MTLBuffer
|
||||||
buffers: []const ?objc.Object = &.{},
|
buffers: []const ?objc.Object = &.{},
|
||||||
textures: []const ?Texture = &.{},
|
textures: []const ?Texture = &.{},
|
||||||
|
/// Set of samplers to use for this step. The index maps to an index
|
||||||
|
/// of a fragment texture, set via setFragmentSamplerState(_:index:).
|
||||||
|
samplers: []const ?Sampler = &.{},
|
||||||
draw: Draw,
|
draw: Draw,
|
||||||
|
|
||||||
/// Describes the draw call for this step.
|
/// Describes the draw call for this step.
|
||||||
|
|
@ -200,6 +204,15 @@ pub fn step(self: *const Self, s: Step) void {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Set samplers.
|
||||||
|
for (s.samplers, 0..) |samp, i| if (samp) |sampler| {
|
||||||
|
self.encoder.msgSend(
|
||||||
|
void,
|
||||||
|
objc.sel("setFragmentSamplerState:atIndex:"),
|
||||||
|
.{ sampler.sampler.value, @as(c_ulong, i) },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Draw!
|
// Draw!
|
||||||
self.encoder.msgSend(
|
self.encoder.msgSend(
|
||||||
void,
|
void,
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,7 @@ pub const Options = struct {
|
||||||
device: objc.Object,
|
device: objc.Object,
|
||||||
pixel_format: mtl.MTLPixelFormat,
|
pixel_format: mtl.MTLPixelFormat,
|
||||||
resource_options: mtl.MTLResourceOptions,
|
resource_options: mtl.MTLResourceOptions,
|
||||||
|
usage: mtl.MTLTextureUsage,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// The underlying MTLTexture Object.
|
/// The underlying MTLTexture Object.
|
||||||
|
|
@ -57,6 +58,7 @@ pub fn init(
|
||||||
desc.setProperty("width", @as(c_ulong, width));
|
desc.setProperty("width", @as(c_ulong, width));
|
||||||
desc.setProperty("height", @as(c_ulong, height));
|
desc.setProperty("height", @as(c_ulong, height));
|
||||||
desc.setProperty("resourceOptions", opts.resource_options);
|
desc.setProperty("resourceOptions", opts.resource_options);
|
||||||
|
desc.setProperty("usage", opts.usage);
|
||||||
|
|
||||||
// Initialize
|
// Initialize
|
||||||
const id = opts.device.msgSend(
|
const id = opts.device.msgSend(
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ const builtin = @import("builtin");
|
||||||
const gl = @import("opengl");
|
const gl = @import("opengl");
|
||||||
|
|
||||||
const OpenGL = @import("../OpenGL.zig");
|
const OpenGL = @import("../OpenGL.zig");
|
||||||
|
const Sampler = @import("Sampler.zig");
|
||||||
const Target = @import("Target.zig");
|
const Target = @import("Target.zig");
|
||||||
const Texture = @import("Texture.zig");
|
const Texture = @import("Texture.zig");
|
||||||
const Pipeline = @import("Pipeline.zig");
|
const Pipeline = @import("Pipeline.zig");
|
||||||
|
|
@ -35,6 +36,7 @@ pub const Step = struct {
|
||||||
uniforms: ?gl.Buffer = null,
|
uniforms: ?gl.Buffer = null,
|
||||||
buffers: []const ?gl.Buffer = &.{},
|
buffers: []const ?gl.Buffer = &.{},
|
||||||
textures: []const ?Texture = &.{},
|
textures: []const ?Texture = &.{},
|
||||||
|
samplers: []const ?Sampler = &.{},
|
||||||
draw: Draw,
|
draw: Draw,
|
||||||
|
|
||||||
/// Describes the draw call for this step.
|
/// Describes the draw call for this step.
|
||||||
|
|
@ -103,6 +105,11 @@ pub fn step(self: *Self, s: Step) void {
|
||||||
_ = tex.texture.bind(tex.target) catch return;
|
_ = tex.texture.bind(tex.target) catch return;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Bind relevant samplers.
|
||||||
|
for (s.samplers, 0..) |s_, i| if (s_) |sampler| {
|
||||||
|
_ = sampler.sampler.bind(@intCast(i)) catch return;
|
||||||
|
};
|
||||||
|
|
||||||
// Bind 0th buffer as the vertex buffer,
|
// Bind 0th buffer as the vertex buffer,
|
||||||
// and bind the rest as storage buffers.
|
// and bind the rest as storage buffers.
|
||||||
if (s.buffers.len > 0) {
|
if (s.buffers.len > 0) {
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
@ -102,7 +102,7 @@ vec4 contrasted_color(float min_ratio, vec4 fg, vec4 bg) {
|
||||||
if (white_ratio > black_ratio) {
|
if (white_ratio > black_ratio) {
|
||||||
return vec4(1.0);
|
return vec4(1.0);
|
||||||
} else {
|
} else {
|
||||||
return vec4(0.0);
|
return vec4(0.0, 0.0, 0.0, 1.0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,13 @@ if [ -n "$GHOSTTY_BASH_INJECT" ]; then
|
||||||
builtin unset GHOSTTY_BASH_RCFILE
|
builtin unset GHOSTTY_BASH_RCFILE
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Add Ghostty binary to PATH if the path feature is enabled
|
||||||
|
if [[ "$GHOSTTY_SHELL_FEATURES" == *"path"* && -n "$GHOSTTY_BIN_DIR" ]]; then
|
||||||
|
if [[ ":$PATH:" != *":$GHOSTTY_BIN_DIR:"* ]]; then
|
||||||
|
export PATH="$PATH:$GHOSTTY_BIN_DIR"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# Sudo
|
# Sudo
|
||||||
if [[ "$GHOSTTY_SHELL_FEATURES" == *"sudo"* && -n "$TERMINFO" ]]; then
|
if [[ "$GHOSTTY_SHELL_FEATURES" == *"sudo"* && -n "$TERMINFO" ]]; then
|
||||||
# Wrap `sudo` command to ensure Ghostty terminfo is preserved.
|
# Wrap `sudo` command to ensure Ghostty terminfo is preserved.
|
||||||
|
|
@ -103,7 +110,7 @@ fi
|
||||||
|
|
||||||
# SSH Integration
|
# SSH Integration
|
||||||
if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-* ]]; then
|
if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-* ]]; then
|
||||||
ssh() {
|
function ssh() {
|
||||||
builtin local ssh_term ssh_opts
|
builtin local ssh_term ssh_opts
|
||||||
ssh_term="xterm-256color"
|
ssh_term="xterm-256color"
|
||||||
ssh_opts=()
|
ssh_opts=()
|
||||||
|
|
|
||||||
|
|
@ -196,6 +196,11 @@
|
||||||
set edit:before-readline = (conj $edit:before-readline $beam~)
|
set edit:before-readline = (conj $edit:before-readline $beam~)
|
||||||
set edit:after-readline = (conj $edit:after-readline {|_| block })
|
set edit:after-readline = (conj $edit:after-readline {|_| block })
|
||||||
}
|
}
|
||||||
|
if (and (has-value $features path) (has-env GHOSTTY_BIN_DIR)) {
|
||||||
|
if (not (has-value $paths $E:GHOSTTY_BIN_DIR)) {
|
||||||
|
set paths = [$@paths $E:GHOSTTY_BIN_DIR]
|
||||||
|
}
|
||||||
|
}
|
||||||
if (and (has-value $features sudo) (not-eq "" $E:TERMINFO) (has-external sudo)) {
|
if (and (has-value $features sudo) (not-eq "" $E:TERMINFO) (has-external sudo)) {
|
||||||
edit:add-var sudo~ $sudo-with-terminfo~
|
edit:add-var sudo~ $sudo-with-terminfo~
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue