diff --git a/CODEOWNERS b/CODEOWNERS index 66e98d833..16e00b6da 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -81,6 +81,10 @@ # - @ghostty-org/localization/* - Anything related to localization # for a specific locale. # +# - @ghosty-org/localization/manager - Manage all localization tasks +# and tooling. They are not responsible for any specific locale but +# are responsible for the overall localization process and tooling. +# # - @ghostty-org/macos - The Ghostty macOS app and any macOS-specific # features, configurations, etc. # @@ -161,6 +165,7 @@ /po/ca_ES.UTF-8.po @ghostty-org/ca_ES /po/de_DE.UTF-8.po @ghostty-org/de_DE /po/es_BO.UTF-8.po @ghostty-org/es_BO +/po/es_AR.UTF-8.po @ghostty-org/es_AR /po/fr_FR.UTF-8.po @ghostty-org/fr_FR /po/ga_IE.UTF-8.po @ghostty-org/ga_IE /po/hu_HU.UTF-8.po @ghostty-org/hu_HU @@ -175,6 +180,7 @@ /po/tr_TR.UTF-8.po @ghostty-org/tr_TR /po/uk_UA.UTF-8.po @ghostty-org/uk_UA /po/zh_CN.UTF-8.po @ghostty-org/zh_CN +/po/ga_IE.UTF-8.po @ghostty-org/ga_IE # Packaging - Snap /snap/ @ghostty-org/snap diff --git a/TODO.md b/TODO.md deleted file mode 100644 index 696bed75f..000000000 --- a/TODO.md +++ /dev/null @@ -1,21 +0,0 @@ -Performance: - -- Loading fonts on startups should probably happen in multiple threads - -Correctness: - -- test wrap against wraptest: https://github.com/mattiase/wraptest - - automate this in some way -- Charsets: UTF-8 vs. ASCII mode - - we only support UTF-8 input right now - - need fallback glyphs if they're not supported - - can effect a crash using `vttest` menu `3 10` since it tries to parse - ASCII as UTF-8. - -Mac: - -- Preferences window - -Major Features: - -- Bell diff --git a/build.zig.zon b/build.zig.zon index e85958aaf..51e2e4538 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -8,8 +8,8 @@ .libxev = .{ // mitchellh/libxev - .url = "https://github.com/mitchellh/libxev/archive/9bc52324d4f0c036a3b244e992680a9fb217bbd3.tar.gz", - .hash = "libxev-0.0.0-86vtc5b1EgB7vFmt9Tk7ySteR5AeEHW7xcR6gK9dMUD3", + .url = "https://github.com/mitchellh/libxev/archive/75a10d0fb374e8eb84948dcfc68d865e755e59c2.tar.gz", + .hash = "libxev-0.0.0-86vtcyMBEwA-UpcjfOICyI2GYG8o6MiRbinS1_8g1_rw", .lazy = true, }, .vaxis = .{ @@ -103,8 +103,8 @@ // Other .apple_sdk = .{ .path = "./pkg/apple-sdk" }, .iterm2_themes = .{ - .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/e436898274ecb89c055da476a8188aa4f79ffb17.tar.gz", - .hash = "N-V-__8AAHncWgThrlpsuJJ2BIINQ6L7SO6SUOT1pEL8UQaX", + .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/6fa671fdc1daf1fcfa025cb960ffa3e7373a2ed8.tar.gz", + .hash = "N-V-__8AAGHcWgTaKLjwmFkxToNT4jgz5VXUHR7hz8TQ2_AS", .lazy = true, }, }, diff --git a/build.zig.zon.json b/build.zig.zon.json index 4217c17aa..1d95ed93a 100644 --- a/build.zig.zon.json +++ b/build.zig.zon.json @@ -54,20 +54,20 @@ "url": "https://deps.files.ghostty.org/imgui-1220bc6b9daceaf7c8c60f3c3998058045ba0c5c5f48ae255ff97776d9cd8bfc6402.tar.gz", "hash": "sha256-oF/QHgTPEat4Hig4fGIdLkIPHmBEyOJ6JeYD6pnveGA=" }, - "N-V-__8AAHncWgThrlpsuJJ2BIINQ6L7SO6SUOT1pEL8UQaX": { + "N-V-__8AAGHcWgTaKLjwmFkxToNT4jgz5VXUHR7hz8TQ2_AS": { "name": "iterm2_themes", - "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/e436898274ecb89c055da476a8188aa4f79ffb17.tar.gz", - "hash": "sha256-C93MSyNgyB+uhvzMQETDXr7839hFyX7NfTMp4HUKs3U=" + "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/6fa671fdc1daf1fcfa025cb960ffa3e7373a2ed8.tar.gz", + "hash": "sha256-g9o2CIc/TfWYoUS/l/HP5KZECD7qNsdQUlFruaKkVz4=" }, "N-V-__8AAJrvXQCqAT8Mg9o_tk6m0yf5Fz-gCNEOKLyTSerD": { "name": "libpng", "url": "https://deps.files.ghostty.org/libpng-1220aa013f0c83da3fb64ea6d327f9173fa008d10e28bc9349eac3463457723b1c66.tar.gz", "hash": "sha256-/syVtGzwXo4/yKQUdQ4LparQDYnp/fF16U/wQcrxoDo=" }, - "libxev-0.0.0-86vtc5b1EgB7vFmt9Tk7ySteR5AeEHW7xcR6gK9dMUD3": { + "libxev-0.0.0-86vtcyMBEwA-UpcjfOICyI2GYG8o6MiRbinS1_8g1_rw": { "name": "libxev", - "url": "https://github.com/mitchellh/libxev/archive/9bc52324d4f0c036a3b244e992680a9fb217bbd3.tar.gz", - "hash": "sha256-VwFByDoptqiN5UkolFQ7TbRhwMERReD9Er2pjxTCYIU=" + "url": "https://github.com/mitchellh/libxev/archive/75a10d0fb374e8eb84948dcfc68d865e755e59c2.tar.gz", + "hash": "sha256-/CSKSuZZfn0aIQlVZ0O8ch5O4gCajYBTTuoetRdo0n4=" }, "N-V-__8AAG3RoQEyRC2Vw7Qoro5SYBf62IHn3HjqtNVY6aWK": { "name": "libxml2", diff --git a/build.zig.zon.nix b/build.zig.zon.nix index 46345871b..fffc639b4 100644 --- a/build.zig.zon.nix +++ b/build.zig.zon.nix @@ -170,11 +170,11 @@ in }; } { - name = "N-V-__8AAHncWgThrlpsuJJ2BIINQ6L7SO6SUOT1pEL8UQaX"; + name = "N-V-__8AAGHcWgTaKLjwmFkxToNT4jgz5VXUHR7hz8TQ2_AS"; path = fetchZigArtifact { name = "iterm2_themes"; - url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/e436898274ecb89c055da476a8188aa4f79ffb17.tar.gz"; - hash = "sha256-C93MSyNgyB+uhvzMQETDXr7839hFyX7NfTMp4HUKs3U="; + url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/6fa671fdc1daf1fcfa025cb960ffa3e7373a2ed8.tar.gz"; + hash = "sha256-g9o2CIc/TfWYoUS/l/HP5KZECD7qNsdQUlFruaKkVz4="; }; } { @@ -186,11 +186,11 @@ in }; } { - name = "libxev-0.0.0-86vtc5b1EgB7vFmt9Tk7ySteR5AeEHW7xcR6gK9dMUD3"; + name = "libxev-0.0.0-86vtcyMBEwA-UpcjfOICyI2GYG8o6MiRbinS1_8g1_rw"; path = fetchZigArtifact { name = "libxev"; - url = "https://github.com/mitchellh/libxev/archive/9bc52324d4f0c036a3b244e992680a9fb217bbd3.tar.gz"; - hash = "sha256-VwFByDoptqiN5UkolFQ7TbRhwMERReD9Er2pjxTCYIU="; + url = "https://github.com/mitchellh/libxev/archive/75a10d0fb374e8eb84948dcfc68d865e755e59c2.tar.gz"; + hash = "sha256-/CSKSuZZfn0aIQlVZ0O8ch5O4gCajYBTTuoetRdo0n4="; }; } { diff --git a/build.zig.zon.txt b/build.zig.zon.txt index b7cb2772f..d032711e5 100644 --- a/build.zig.zon.txt +++ b/build.zig.zon.txt @@ -27,8 +27,8 @@ https://deps.files.ghostty.org/ziglyph-b89d43d1e3fb01b6074bc1f7fc980324b04d26a5. https://deps.files.ghostty.org/zlib-1220fed0c74e1019b3ee29edae2051788b080cd96e90d56836eea857b0b966742efb.tar.gz https://github.com/glfw/glfw/archive/e7ea71be039836da3a98cea55ae5569cb5eb885c.tar.gz https://github.com/jcollie/ghostty-gobject/releases/download/0.14.0-2025-03-18-21-1/ghostty-gobject-0.14.0-2025-03-18-21-1.tar.zst -https://github.com/mbadolato/iTerm2-Color-Schemes/archive/e436898274ecb89c055da476a8188aa4f79ffb17.tar.gz -https://github.com/mitchellh/libxev/archive/9bc52324d4f0c036a3b244e992680a9fb217bbd3.tar.gz +https://github.com/mbadolato/iTerm2-Color-Schemes/archive/6fa671fdc1daf1fcfa025cb960ffa3e7373a2ed8.tar.gz +https://github.com/mitchellh/libxev/archive/75a10d0fb374e8eb84948dcfc68d865e755e59c2.tar.gz https://github.com/mitchellh/zig-objc/archive/3ab0d37c7d6b933d6ded1b3a35b6b60f05590a98.tar.gz https://github.com/natecraddock/zf/archive/7aacbe6d155d64d15937ca95ca6c014905eb531f.tar.gz https://github.com/vancluever/z2d/archive/1bf4bc81819385f4b24596445c9a7cf3b3592b08.tar.gz diff --git a/dist/linux/app.desktop b/dist/linux/app.desktop.in similarity index 72% rename from dist/linux/app.desktop rename to dist/linux/app.desktop.in index 6e464ea87..c39164158 100644 --- a/dist/linux/app.desktop +++ b/dist/linux/app.desktop.in @@ -1,13 +1,15 @@ [Desktop Entry] -Name=Ghostty +Version=1.0 +Name=@NAME@ Type=Application Comment=A terminal emulator -Exec=ghostty +TryExec=@GHOSTTY@ +Exec=@GHOSTTY@ --launched-from=desktop Icon=com.mitchellh.ghostty Categories=System;TerminalEmulator; Keywords=terminal;tty;pty; StartupNotify=true -StartupWMClass=com.mitchellh.ghostty +StartupWMClass=@APPID@ Terminal=false Actions=new-window; X-GNOME-UsesNotifications=true @@ -16,7 +18,8 @@ X-TerminalArgTitle=--title= X-TerminalArgAppId=--class= X-TerminalArgDir=--working-directory= X-TerminalArgHold=--wait-after-command +DBusActivatable=true [Desktop Action new-window] Name=New Window -Exec=ghostty +Exec=@GHOSTTY@ --launched-from=desktop diff --git a/dist/linux/com.mitchellh.ghostty.metainfo.xml b/dist/linux/com.mitchellh.ghostty.metainfo.xml.in similarity index 94% rename from dist/linux/com.mitchellh.ghostty.metainfo.xml rename to dist/linux/com.mitchellh.ghostty.metainfo.xml.in index 0424d3a09..42ccc2754 100644 --- a/dist/linux/com.mitchellh.ghostty.metainfo.xml +++ b/dist/linux/com.mitchellh.ghostty.metainfo.xml.in @@ -1,8 +1,8 @@ - com.mitchellh.ghostty - com.mitchellh.ghostty.desktop - Ghostty + @APPID@ + @APPID@.desktop + @NAME@ https://ghostty.org https://ghostty.org/docs https://github.com/ghostty-org/ghostty/discussions diff --git a/dist/linux/dbus.service.flatpak.in b/dist/linux/dbus.service.flatpak.in new file mode 100644 index 000000000..213cda78f --- /dev/null +++ b/dist/linux/dbus.service.flatpak.in @@ -0,0 +1,3 @@ +[D-BUS Service] +Name=@APPID@ +Exec=@GHOSTTY@ --launched-from=dbus diff --git a/dist/linux/dbus.service.in b/dist/linux/dbus.service.in new file mode 100644 index 000000000..2f782a7ed --- /dev/null +++ b/dist/linux/dbus.service.in @@ -0,0 +1,4 @@ +[D-BUS Service] +Name=@APPID@ +SystemdService=@APPID@.service +Exec=@GHOSTTY@ --launched-from=dbus diff --git a/dist/linux/systemd.service.in b/dist/linux/systemd.service.in new file mode 100644 index 000000000..b0ef3d59a --- /dev/null +++ b/dist/linux/systemd.service.in @@ -0,0 +1,7 @@ +[Unit] +Description=@NAME@ + +[Service] +Type=dbus +BusName=@APPID@ +ExecStart=@GHOSTTY@ --launched-from=systemd diff --git a/flatpak/com.mitchellh.ghostty-debug.yml b/flatpak/com.mitchellh.ghostty-debug.yml index 8a2c0056e..fe4722ef5 100644 --- a/flatpak/com.mitchellh.ghostty-debug.yml +++ b/flatpak/com.mitchellh.ghostty-debug.yml @@ -6,11 +6,7 @@ sdk-extensions: - org.freedesktop.Sdk.Extension.ziglang default-branch: tip command: ghostty -# Integrate the rename into zig build, maybe? -rename-desktop-file: com.mitchellh.ghostty.desktop -rename-appdata-file: com.mitchellh.ghostty.metainfo.xml rename-icon: com.mitchellh.ghostty -desktop-file-name-suffix: " (Debug)" finish-args: # 3D rendering - --device=dri diff --git a/flatpak/zig-packages.json b/flatpak/zig-packages.json index 32bd8bd54..81024bb26 100644 --- a/flatpak/zig-packages.json +++ b/flatpak/zig-packages.json @@ -67,9 +67,9 @@ }, { "type": "archive", - "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/e436898274ecb89c055da476a8188aa4f79ffb17.tar.gz", - "dest": "vendor/p/N-V-__8AAHncWgThrlpsuJJ2BIINQ6L7SO6SUOT1pEL8UQaX", - "sha256": "0bddcc4b2360c81fae86fccc4044c35ebefcdfd845c97ecd7d3329e0750ab375" + "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/6fa671fdc1daf1fcfa025cb960ffa3e7373a2ed8.tar.gz", + "dest": "vendor/p/N-V-__8AAGHcWgTaKLjwmFkxToNT4jgz5VXUHR7hz8TQ2_AS", + "sha256": "83da3608873f4df598a144bf97f1cfe4a644083eea36c75052516bb9a2a4573e" }, { "type": "archive", @@ -79,9 +79,9 @@ }, { "type": "archive", - "url": "https://github.com/mitchellh/libxev/archive/9bc52324d4f0c036a3b244e992680a9fb217bbd3.tar.gz", - "dest": "vendor/p/libxev-0.0.0-86vtc5b1EgB7vFmt9Tk7ySteR5AeEHW7xcR6gK9dMUD3", - "sha256": "570141c83a29b6a88de5492894543b4db461c0c11145e0fd12bda98f14c26085" + "url": "https://github.com/mitchellh/libxev/archive/75a10d0fb374e8eb84948dcfc68d865e755e59c2.tar.gz", + "dest": "vendor/p/libxev-0.0.0-86vtcyMBEwA-UpcjfOICyI2GYG8o6MiRbinS1_8g1_rw", + "sha256": "fc248a4ae6597e7d1a2109556743bc721e4ee2009a8d80534eea1eb51768d27e" }, { "type": "archive", diff --git a/macos/Sources/Features/Splits/SplitTree.swift b/macos/Sources/Features/Splits/SplitTree.swift index 1c4be7dd6..b353f6cbe 100644 --- a/macos/Sources/Features/Splits/SplitTree.swift +++ b/macos/Sources/Features/Splits/SplitTree.swift @@ -610,14 +610,18 @@ extension SplitTree.Node { return (self, 1) case .split(let split): - // Recursively equalize children - let (leftNode, leftWeight) = split.left.equalizeWithWeight() - let (rightNode, rightWeight) = split.right.equalizeWithWeight() - + // Calculate weights based on split direction + let leftWeight = split.left.weightForDirection(split.direction) + let rightWeight = split.right.weightForDirection(split.direction) + // Calculate new ratio based on relative weights let totalWeight = leftWeight + rightWeight let newRatio = Double(leftWeight) / Double(totalWeight) - + + // Recursively equalize children + let (leftNode, _) = split.left.equalizeWithWeight() + let (rightNode, _) = split.right.equalizeWithWeight() + // Create new split with equalized ratio let newSplit = Split( direction: split.direction, @@ -630,6 +634,23 @@ extension SplitTree.Node { } } + /// Calculate weight for equalization based on split direction. + /// Children with the same direction contribute their full weight, + /// children with different directions count as 1. + private func weightForDirection(_ direction: SplitTree.Direction) -> Int { + switch self { + case .leaf: + return 1 + case .split(let split): + if split.direction == direction { + return split.left.weightForDirection(direction) + split.right.weightForDirection(direction) + } else { + return 1 + } + } + } + + /// Calculate the bounds of all views in this subtree based on split ratios func calculateViewBounds(in bounds: CGRect) -> [(view: ViewType, bounds: CGRect)] { switch self { diff --git a/pkg/gtk4-layer-shell/src/main.zig b/pkg/gtk4-layer-shell/src/main.zig index 06936bba2..f7848ea94 100644 --- a/pkg/gtk4-layer-shell/src/main.zig +++ b/pkg/gtk4-layer-shell/src/main.zig @@ -1,3 +1,5 @@ +const std = @import("std"); + const c = @cImport({ @cInclude("gtk4-layer-shell.h"); }); @@ -31,6 +33,14 @@ pub fn getProtocolVersion() c_uint { return c.gtk_layer_get_protocol_version(); } +pub fn getLibraryVersion() std.SemanticVersion { + return .{ + .major = c.gtk_layer_get_major_version(), + .minor = c.gtk_layer_get_minor_version(), + .patch = c.gtk_layer_get_micro_version(), + }; +} + pub fn initForWindow(window: *gtk.Window) void { c.gtk_layer_init_for_window(@ptrCast(window)); } diff --git a/pkg/opengl/Texture.zig b/pkg/opengl/Texture.zig index 4a1d61433..2c8e05eff 100644 --- a/pkg/opengl/Texture.zig +++ b/pkg/opengl/Texture.zig @@ -74,6 +74,9 @@ pub const InternalFormat = enum(c_int) { srgb = c.GL_SRGB8, srgba = c.GL_SRGB8_ALPHA8, + rgba_compressed = c.GL_COMPRESSED_RGBA_BPTC_UNORM, + srgba_compressed = c.GL_COMPRESSED_SRGB_ALPHA_BPTC_UNORM, + // There are so many more that I haven't filled in. _, }; @@ -126,7 +129,6 @@ pub const Binding = struct { internal_format: InternalFormat, width: c.GLsizei, height: c.GLsizei, - border: c.GLint, format: Format, typ: DataType, data: ?*const anyopaque, @@ -137,7 +139,7 @@ pub const Binding = struct { @intFromEnum(internal_format), width, height, - border, + 0, @intFromEnum(format), @intFromEnum(typ), data, diff --git a/pkg/wuffs/src/main.zig b/pkg/wuffs/src/main.zig index f282261c2..89f3c008c 100644 --- a/pkg/wuffs/src/main.zig +++ b/pkg/wuffs/src/main.zig @@ -3,11 +3,12 @@ const std = @import("std"); pub const png = @import("png.zig"); pub const jpeg = @import("jpeg.zig"); pub const swizzle = @import("swizzle.zig"); +pub const Error = @import("error.zig").Error; pub const ImageData = struct { width: u32, height: u32, - data: []const u8, + data: []u8, }; test { diff --git a/pkg/wuffs/src/swizzle.zig b/pkg/wuffs/src/swizzle.zig index d57da98a9..352cf2b50 100644 --- a/pkg/wuffs/src/swizzle.zig +++ b/pkg/wuffs/src/swizzle.zig @@ -33,6 +33,24 @@ pub fn rgbToRgba(alloc: Allocator, src: []const u8) Error![]u8 { ); } +pub fn bgrToRgba(alloc: Allocator, src: []const u8) Error![]u8 { + return swizzle( + alloc, + src, + c.WUFFS_BASE__PIXEL_FORMAT__BGR, + c.WUFFS_BASE__PIXEL_FORMAT__RGBA_PREMUL, + ); +} + +pub fn bgraToRgba(alloc: Allocator, src: []const u8) Error![]u8 { + return swizzle( + alloc, + src, + c.WUFFS_BASE__PIXEL_FORMAT__BGRA_PREMUL, + c.WUFFS_BASE__PIXEL_FORMAT__RGBA_PREMUL, + ); +} + fn swizzle( alloc: Allocator, src: []const u8, diff --git a/po/com.mitchellh.ghostty.pot b/po/com.mitchellh.ghostty.pot index da0efbbee..7691f91b5 100644 --- a/po/com.mitchellh.ghostty.pot +++ b/po/com.mitchellh.ghostty.pot @@ -1,5 +1,5 @@ # SOME DESCRIPTIVE TITLE. -# Copyright (C) YEAR Mitchell Hashimoto, Ghostty contributors +# Copyright (C) YEAR "Mitchell Hashimoto, Ghostty contributors" # This file is distributed under the same license as the com.mitchellh.ghostty package. # FIRST AUTHOR , YEAR. # @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: com.mitchellh.ghostty\n" "Report-Msgid-Bugs-To: m@mitchellh.com\n" -"POT-Creation-Date: 2025-04-23 16:58+0800\n" +"POT-Creation-Date: 2025-06-28 17:01+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -26,7 +26,8 @@ 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/CloseDialog.zig:44 +#: 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 "" @@ -35,22 +36,26 @@ 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 "" @@ -89,7 +94,7 @@ 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.5/ccw-paste.blp:11 src/apprt/gtk/ui/1.2/ccw-paste.blp:11 msgid "Paste" msgstr "" @@ -119,7 +124,7 @@ 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:255 +#: src/apprt/gtk/Window.zig:263 msgid "New Tab" msgstr "" @@ -160,7 +165,7 @@ msgid "Terminal Inspector" msgstr "" #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:107 -#: src/apprt/gtk/Window.zig:1024 +#: src/apprt/gtk/Window.zig:1036 msgid "About Ghostty" msgstr "" @@ -170,10 +175,13 @@ 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." @@ -181,52 +189,67 @@ 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.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.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/Window.zig:208 +#: src/apprt/gtk/Window.zig:216 msgid "Main Menu" msgstr "" -#: src/apprt/gtk/Window.zig:229 +#: src/apprt/gtk/Window.zig:238 msgid "View Open Tabs" msgstr "" -#: src/apprt/gtk/Window.zig:256 +#: src/apprt/gtk/Window.zig:264 msgid "New Split" msgstr "" -#: src/apprt/gtk/Window.zig:319 +#: src/apprt/gtk/Window.zig:327 msgid "" "⚠️ You're running a debug build of Ghostty! Performance will be degraded." msgstr "" -#: src/apprt/gtk/Window.zig:765 +#: src/apprt/gtk/Window.zig:773 msgid "Reloaded the configuration" msgstr "" -#: src/apprt/gtk/Window.zig:1005 +#: src/apprt/gtk/Window.zig:1017 msgid "Ghostty Developers" msgstr "" @@ -270,6 +293,6 @@ msgstr "" msgid "The currently running process in this split will be terminated." msgstr "" -#: src/apprt/gtk/Surface.zig:1243 +#: src/apprt/gtk/Surface.zig:1257 msgid "Copied to clipboard" msgstr "" diff --git a/po/es_AR.UTF-8.po b/po/es_AR.UTF-8.po new file mode 100644 index 000000000..3cd0625c8 --- /dev/null +++ b/po/es_AR.UTF-8.po @@ -0,0 +1,285 @@ +# Spanish translations for com.mitchellh.ghostty package. +# Copyright (C) 2025 Mitchell Hashimoto +# This file is distributed under the same license as the com.mitchellh.ghostty package. +# Alan Moyano , 2025. +# +msgid "" +msgstr "" +"Project-Id-Version: com.mitchellh.ghostty\n" +"Report-Msgid-Bugs-To: m@mitchellh.com\n" +"POT-Creation-Date: 2025-04-23 16:58+0800\n" +"PO-Revision-Date: 2025-05-19 20:17-0300\n" +"Last-Translator: Alan Moyano \n" +"Language-Team: Argentinian \n" +"Language: es_AR\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: src/apprt/gtk/ui/1.5/prompt-title-dialog.blp:5 +msgid "Change Terminal Title" +msgstr "Cambiar el título de la terminal" + +#: src/apprt/gtk/ui/1.5/prompt-title-dialog.blp:6 +msgid "Leave blank to restore the default title." +msgstr "Dejar en blanco para restaurar el título predeterminado." + +#: src/apprt/gtk/ui/1.5/prompt-title-dialog.blp:9 +#: src/apprt/gtk/ui/1.5/ccw-paste.blp:10 src/apprt/gtk/CloseDialog.zig:44 +msgid "Cancel" +msgstr "Cancelar" + +#: src/apprt/gtk/ui/1.5/prompt-title-dialog.blp:10 +msgid "OK" +msgstr "Aceptar" + +#: src/apprt/gtk/ui/1.5/config-errors-dialog.blp:5 +msgid "Configuration Errors" +msgstr "Errores de configuración" + +#: src/apprt/gtk/ui/1.5/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 "" +"Se encontraron uno o más errores de configuración. Por favor revisá los " +"errores a continuación, y recargá tu configuración o ignorá estos errores." + +#: src/apprt/gtk/ui/1.5/config-errors-dialog.blp:9 +msgid "Ignore" +msgstr "Ignorar" + +#: 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 +msgid "Reload Configuration" +msgstr "Recargar configuración" + +#: 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 "Dividir arriba" + +#: 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 "Dividir abajo" + +#: 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 "Dividir a la izquierda" + +#: 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 "Dividir a la derecha" + +#: src/apprt/gtk/ui/1.5/command-palette.blp:16 +msgid "Execute a command…" +msgstr "Ejecutar un comando…" + +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:6 +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:6 +msgid "Copy" +msgstr "Copiar" + +#: 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 +msgid "Paste" +msgstr "Pegar" + +#: 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 "Limpiar" + +#: 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 "Reiniciar" + +#: 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 "Dividir" + +#: 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 "Cambiar título…" + +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:59 +msgid "Tab" +msgstr "Pestaña" + +#: 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:255 +msgid "New Tab" +msgstr "Nueva pestaña" + +#: 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 "Cerrar pestaña" + +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:73 +msgid "Window" +msgstr "Ventana" + +#: 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 "Nueva ventana" + +#: 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 "Cerrar ventana" + +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:89 +msgid "Config" +msgstr "Configuración" + +#: 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 "Abrir configuración" + +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:85 +msgid "Command Palette" +msgstr "Paleta de comandos" + +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:90 +msgid "Terminal Inspector" +msgstr "Inspector de la terminal" + +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:107 +#: src/apprt/gtk/Window.zig:1024 +msgid "About Ghostty" +msgstr "Acerca de Ghostty" + +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:112 +msgid "Quit" +msgstr "Salir" + +#: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:6 +#: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:6 +msgid "Authorize Clipboard Access" +msgstr "Autorizar acceso al portapapeles" + +#: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:7 +msgid "" +"An application is attempting to read from the clipboard. The current " +"clipboard contents are shown below." +msgstr "" +"Una aplicación está intentando leer desde el portapapeles. El contenido " +"actual del portapapeles se muestra a continuación." + +#: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:10 +#: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:10 +msgid "Deny" +msgstr "Denegar" + +#: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:11 +#: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:11 +msgid "Allow" +msgstr "Permitir" + +#: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:7 +msgid "" +"An application is attempting to write to the clipboard. The current " +"clipboard contents are shown below." +msgstr "" +"Una aplicación está intentando escribir en el portapapeles. El contenido " +"actual del portapapeles se muestra a continuación." + +#: src/apprt/gtk/ui/1.5/ccw-paste.blp:6 +msgid "Warning: Potentially Unsafe Paste" +msgstr "Advertencia: Pegado potencialmente inseguro" + +#: src/apprt/gtk/ui/1.5/ccw-paste.blp:7 +msgid "" +"Pasting this text into the terminal may be dangerous as it looks like some " +"commands may be executed." +msgstr "" +"Pegar este texto en la terminal puede ser peligroso ya que parece que " +"algunos comandos podrían ejecutarse." + +#: src/apprt/gtk/Window.zig:208 +msgid "Main Menu" +msgstr "Menú principal" + +#: src/apprt/gtk/Window.zig:229 +msgid "View Open Tabs" +msgstr "Ver pestañas abiertas" + +#: src/apprt/gtk/Window.zig:256 +msgid "New Split" +msgstr "Nueva división" + +#: src/apprt/gtk/Window.zig:319 +msgid "" +"⚠️ You're running a debug build of Ghostty! Performance will be degraded." +msgstr "" +"⚠️ Estás ejecutando una versión de depuración de Ghostty. El rendimiento no " +"será óptimo." + +#: src/apprt/gtk/Window.zig:765 +msgid "Reloaded the configuration" +msgstr "Configuración recargada" + +#: src/apprt/gtk/Window.zig:1005 +msgid "Ghostty Developers" +msgstr "Desarrolladores de Ghostty" + +#: src/apprt/gtk/inspector.zig:144 +msgid "Ghostty: Terminal Inspector" +msgstr "Ghostty: Inspector de la terminal" + +#: src/apprt/gtk/CloseDialog.zig:47 +msgid "Close" +msgstr "Cerrar" + +#: src/apprt/gtk/CloseDialog.zig:87 +msgid "Quit Ghostty?" +msgstr "¿Salir de Ghostty?" + +#: src/apprt/gtk/CloseDialog.zig:88 +msgid "Close Window?" +msgstr "¿Cerrar ventana?" + +#: src/apprt/gtk/CloseDialog.zig:89 +msgid "Close Tab?" +msgstr "¿Cerrar pestaña?" + +#: src/apprt/gtk/CloseDialog.zig:90 +msgid "Close Split?" +msgstr "¿Cerrar división?" + +#: src/apprt/gtk/CloseDialog.zig:96 +msgid "All terminal sessions will be terminated." +msgstr "Todas las sesiones de terminal serán terminadas." + +#: src/apprt/gtk/CloseDialog.zig:97 +msgid "All terminal sessions in this window will be terminated." +msgstr "Todas las sesiones de terminal en esta ventana serán terminadas." + +#: src/apprt/gtk/CloseDialog.zig:98 +msgid "All terminal sessions in this tab will be terminated." +msgstr "Todas las sesiones de terminal en esta pestaña serán terminadas." + +#: src/apprt/gtk/CloseDialog.zig:99 +msgid "The currently running process in this split will be terminated." +msgstr "El proceso actualmente en ejecución en esta división será terminado." + +#: src/apprt/gtk/Surface.zig:1243 +msgid "Copied to clipboard" +msgstr "Copiado al portapapeles" diff --git a/po/ga_IE.UTF-8.po b/po/ga_IE.UTF-8.po new file mode 100644 index 000000000..686d22d76 --- /dev/null +++ b/po/ga_IE.UTF-8.po @@ -0,0 +1,286 @@ +# Irish translations for com.mitchellh.ghostty package. +# Copyright (C) 2025 Mitchell Hashimoto, Ghostty contributors +# This file is distributed under the same license as the com.mitchellh.ghostty package. +# Aindriú Mac Giolla Eoin , 2025. +# +msgid "" +msgstr "" +"Project-Id-Version: com.mitchellh.ghostty\n" +"Report-Msgid-Bugs-To: m@mitchellh.com\n" +"POT-Creation-Date: 2025-04-23 16:58+0800\n" +"PO-Revision-Date: 2025-06-29 21:15+0100\n" +"Last-Translator: Aindriú Mac Giolla Eoin \n" +"Language-Team: Irish \n" +"Language: ga\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==1 ? 0 : n==2 ? 1 : 2;\n" +"X-Generator: Poedit 3.4.4\n" + +#: src/apprt/gtk/ui/1.5/prompt-title-dialog.blp:5 +msgid "Change Terminal Title" +msgstr "Athraigh teideal teirminéil" + +#: src/apprt/gtk/ui/1.5/prompt-title-dialog.blp:6 +msgid "Leave blank to restore the default title." +msgstr "Fág bán chun an teideal réamhshocraithe a athbhunú." + +#: src/apprt/gtk/ui/1.5/prompt-title-dialog.blp:9 +#: src/apprt/gtk/ui/1.5/ccw-paste.blp:10 src/apprt/gtk/CloseDialog.zig:44 +msgid "Cancel" +msgstr "Cealaigh" + +#: src/apprt/gtk/ui/1.5/prompt-title-dialog.blp:10 +msgid "OK" +msgstr "Ceart go leor" + +#: src/apprt/gtk/ui/1.5/config-errors-dialog.blp:5 +msgid "Configuration Errors" +msgstr "Earráidí cumraíochta" + +#: src/apprt/gtk/ui/1.5/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 "" +"Fuarthas earráid chumraíochta amháin nó níos mó. Athbhreithnigh na hearráidí " +"thíos, agus athlódáil do chumraíocht nó déan neamhaird de na hearráidí seo." + +#: src/apprt/gtk/ui/1.5/config-errors-dialog.blp:9 +msgid "Ignore" +msgstr "Déan neamhaird de" + +#: 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 +msgid "Reload Configuration" +msgstr "Athlódáil cumraíocht" + +#: 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 "Scoilt suas" + +#: 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 "Scoilt síos" + +#: 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 "Scoilt ar chlé" + +#: 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 "Scoilt ar dheis" + +#: src/apprt/gtk/ui/1.5/command-palette.blp:16 +msgid "Execute a command…" +msgstr "Rith ordú…" + +#: 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 "Cóipeáil" + +#: 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 +msgid "Paste" +msgstr "Greamaigh" + +#: 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 "Glan" + +#: 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 "Athshocraigh" + +#: 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 "Scoilt" + +#: 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 "Athraigh teideal…" + +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:59 +msgid "Tab" +msgstr "Táb" + +#: 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:255 +msgid "New Tab" +msgstr "Táb nua" + +#: 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 "Dún táb" + +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:73 +msgid "Window" +msgstr "Fuinneog" + +#: 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 "Fuinneog nua" + +#: 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 "Dún fuinneog" + +#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:89 +msgid "Config" +msgstr "Cumraíocht" + +#: 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 "Oscail cumraíocht" + +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:85 +msgid "Command Palette" +msgstr "Pailéad ordaithe" + +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:90 +msgid "Terminal Inspector" +msgstr "Cigire teirminéil" + +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:107 +#: src/apprt/gtk/Window.zig:1024 +msgid "About Ghostty" +msgstr "Maidir le Ghostty" + +#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:112 +msgid "Quit" +msgstr "Scoir" + +#: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:6 +#: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:6 +msgid "Authorize Clipboard Access" +msgstr "Údarú rochtain ar an ngearrthaisce" + +#: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:7 +msgid "" +"An application is attempting to read from the clipboard. The current " +"clipboard contents are shown below." +msgstr "" +"Tá feidhmchlár ag iarraidh léamh ón ngearrthaisce. Taispeántar ábhar reatha " +"an ghearrthaisce thíos." + +#: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:10 +#: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:10 +msgid "Deny" +msgstr "Diúltaigh" + +#: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:11 +#: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:11 +msgid "Allow" +msgstr "Ceadaigh" + +#: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:7 +msgid "" +"An application is attempting to write to the clipboard. The current " +"clipboard contents are shown below." +msgstr "" +"Tá feidhmchlár ag iarraidh scríobh chuig an ngearrthaisce. Taispeántar ábhar " +"reatha an ghearrthaisce thíos." + +#: src/apprt/gtk/ui/1.5/ccw-paste.blp:6 +msgid "Warning: Potentially Unsafe Paste" +msgstr "Rabhadh: Greamaigh a d'fhéadfadh a bheith neamhshábháilte" + +#: src/apprt/gtk/ui/1.5/ccw-paste.blp:7 +msgid "" +"Pasting this text into the terminal may be dangerous as it looks like some " +"commands may be executed." +msgstr "" +"D’fhéadfadh sé a bheith contúirteach an téacs seo a ghreamú isteach sa " +"teirminéal, toisc go d'fhéadfadh roinnt orduithe a fhorghníomhú." + +#: src/apprt/gtk/Window.zig:208 +msgid "Main Menu" +msgstr "Príomh-Roghchlár" + +#: src/apprt/gtk/Window.zig:229 +msgid "View Open Tabs" +msgstr "Féach ar na táib oscailte" + +#: src/apprt/gtk/Window.zig:256 +msgid "New Split" +msgstr "Scoilt nua" + +#: src/apprt/gtk/Window.zig:319 +msgid "" +"⚠️ You're running a debug build of Ghostty! Performance will be degraded." +msgstr "" +"⚠️ Tá leagan dífhabhtaithe de Ghostty á rith agat! Laghdófar an fheidhmíocht." + +#: src/apprt/gtk/Window.zig:765 +msgid "Reloaded the configuration" +msgstr "Tá an chumraíocht athlódáilte" + +#: src/apprt/gtk/Window.zig:1005 +msgid "Ghostty Developers" +msgstr "Forbróirí Ghostty" + +#: src/apprt/gtk/inspector.zig:144 +msgid "Ghostty: Terminal Inspector" +msgstr "Ghostty: Cigire teirminéil" + +#: src/apprt/gtk/CloseDialog.zig:47 +msgid "Close" +msgstr "Dún" + +#: src/apprt/gtk/CloseDialog.zig:87 +msgid "Quit Ghostty?" +msgstr "Scoir Ghostty?" + +#: src/apprt/gtk/CloseDialog.zig:88 +msgid "Close Window?" +msgstr "Dún fuinneog?" + +#: src/apprt/gtk/CloseDialog.zig:89 +msgid "Close Tab?" +msgstr "Dún táb?" + +#: src/apprt/gtk/CloseDialog.zig:90 +msgid "Close Split?" +msgstr "Dún an scoilt?" + +#: src/apprt/gtk/CloseDialog.zig:96 +msgid "All terminal sessions will be terminated." +msgstr "Cuirfear deireadh le gach seisiún teirminéil." + +#: src/apprt/gtk/CloseDialog.zig:97 +msgid "All terminal sessions in this window will be terminated." +msgstr "Cuirfear deireadh le gach seisiún teirminéil san fhuinneog seo." + +#: src/apprt/gtk/CloseDialog.zig:98 +msgid "All terminal sessions in this tab will be terminated." +msgstr "Cuirfear deireadh le gach seisiún teirminéil sa táb seo." + +#: src/apprt/gtk/CloseDialog.zig:99 +msgid "The currently running process in this split will be terminated." +msgstr "" +"Cuirfear deireadh leis an bpróiseas atá ar siúl faoi láthair sa scoilt seo." + +#: src/apprt/gtk/Surface.zig:1243 +msgid "Copied to clipboard" +msgstr "Cóipeáilte chuig an ghearrthaisce" diff --git a/po/zh_CN.UTF-8.po b/po/zh_CN.UTF-8.po index 77be8a351..17a6dc921 100644 --- a/po/zh_CN.UTF-8.po +++ b/po/zh_CN.UTF-8.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: com.mitchellh.ghostty\n" "Report-Msgid-Bugs-To: m@mitchellh.com\n" -"POT-Creation-Date: 2025-04-23 16:58+0800\n" +"POT-Creation-Date: 2025-06-28 17:01+0200\n" "PO-Revision-Date: 2025-02-27 09:16+0100\n" "Last-Translator: Leah \n" "Language-Team: Chinese (simplified) \n" @@ -26,7 +26,8 @@ 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/CloseDialog.zig:44 +#: 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 "取消" @@ -35,10 +36,12 @@ 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." @@ -46,12 +49,14 @@ 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 "重新加载配置" @@ -90,7 +95,7 @@ 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.5/ccw-paste.blp:11 src/apprt/gtk/ui/1.2/ccw-paste.blp:11 msgid "Paste" msgstr "粘贴" @@ -120,7 +125,7 @@ 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:255 +#: src/apprt/gtk/Window.zig:263 msgid "New Tab" msgstr "新建标签页" @@ -161,7 +166,7 @@ msgid "Terminal Inspector" msgstr "终端调试器" #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:107 -#: src/apprt/gtk/Window.zig:1024 +#: src/apprt/gtk/Window.zig:1036 msgid "About Ghostty" msgstr "关于 Ghostty" @@ -171,10 +176,13 @@ 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." @@ -182,52 +190,67 @@ 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.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.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/Window.zig:208 +#: src/apprt/gtk/Window.zig:216 msgid "Main Menu" msgstr "主菜单" -#: src/apprt/gtk/Window.zig:229 +#: src/apprt/gtk/Window.zig:238 msgid "View Open Tabs" msgstr "浏览标签页" -#: src/apprt/gtk/Window.zig:256 +#: src/apprt/gtk/Window.zig:264 msgid "New Split" msgstr "新建分屏" -#: src/apprt/gtk/Window.zig:319 +#: src/apprt/gtk/Window.zig:327 msgid "" "⚠️ You're running a debug build of Ghostty! Performance will be degraded." msgstr "⚠️ Ghostty 正在以调试模式运行!性能将大打折扣。" -#: src/apprt/gtk/Window.zig:765 +#: src/apprt/gtk/Window.zig:773 msgid "Reloaded the configuration" msgstr "已重新加载配置" -#: src/apprt/gtk/Window.zig:1005 +#: src/apprt/gtk/Window.zig:1017 msgid "Ghostty Developers" msgstr "Ghostty 开发团队" @@ -271,6 +294,6 @@ msgstr "标签页内所有运行中的进程将被终止。" msgid "The currently running process in this split will be terminated." msgstr "分屏内正在运行中的进程将被终止。" -#: src/apprt/gtk/Surface.zig:1243 +#: src/apprt/gtk/Surface.zig:1257 msgid "Copied to clipboard" msgstr "已复制至剪贴板" diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index d7fc63712..df8d6ae53 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -76,13 +76,11 @@ parts: - git - patchelf - gettext + # TODO: Remove -fno-sys=gtk4-layer-shell when we upgrade to a version that packages it Ubuntu 24.10+ override-build: | craftctl set version=$(cat VERSION) - $CRAFT_PART_SRC/../../zig/src/zig build -Dpatch-rpath=\$ORIGIN/../usr/lib/$CRAFT_ARCH_TRIPLET_BUILD_FOR:/snap/core24/current/lib/$CRAFT_ARCH_TRIPLET_BUILD_FOR -Doptimize=ReleaseFast -Dcpu=baseline + $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 cp -rp zig-out/* $CRAFT_PART_INSTALL/ - # Install libgtk4-layer-shell.so - mkdir -p $CRAFT_PART_INSTALL/usr/lib/$CRAFT_ARCH_TRIPLET_BUILD_FOR - cp .zig-cache/*/*/libgtk4-layer-shell.so $CRAFT_PART_INSTALL/usr/lib/$CRAFT_ARCH_TRIPLET_BUILD_FOR/ 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 libs: diff --git a/src/App.zig b/src/App.zig index 3bbeff2c8..02089ae5b 100644 --- a/src/App.zig +++ b/src/App.zig @@ -76,34 +76,38 @@ first: bool = true, pub const CreateError = Allocator.Error || font.SharedGridSet.InitError; +/// Create a new app instance. This returns a stable pointer to the app +/// instance which is required for callbacks. +pub fn create(alloc: Allocator) CreateError!*App { + var app = try alloc.create(App); + errdefer alloc.destroy(app); + try app.init(alloc); + return app; +} + /// Initialize the main app instance. This creates the main window, sets /// up the renderer state, compiles the shaders, etc. This is the primary /// "startup" logic. /// /// After calling this function, well behaved apprts should then call /// `focusEvent` to set the initial focus state of the app. -pub fn create( +pub fn init( + self: *App, alloc: Allocator, -) CreateError!*App { - var app = try alloc.create(App); - errdefer alloc.destroy(app); - +) CreateError!void { var font_grid_set = try font.SharedGridSet.init(alloc); errdefer font_grid_set.deinit(); - app.* = .{ + self.* = .{ .alloc = alloc, .surfaces = .{}, .mailbox = .{}, .font_grid_set = font_grid_set, .config_conditional_state = .{}, }; - errdefer app.surfaces.deinit(alloc); - - return app; } -pub fn destroy(self: *App) void { +pub fn deinit(self: *App) void { // Clean up all our surfaces for (self.surfaces.items) |surface| surface.deinit(); self.surfaces.deinit(self.alloc); @@ -114,7 +118,13 @@ pub fn destroy(self: *App) void { // should gracefully close all surfaces. assert(self.font_grid_set.count() == 0); self.font_grid_set.deinit(); +} +pub fn destroy(self: *App) void { + // Deinitialize the app + self.deinit(); + + // Free the app memory self.alloc.destroy(self); } diff --git a/src/Surface.zig b/src/Surface.zig index a25b200f7..390adf91b 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -138,6 +138,9 @@ child_exited: bool = false, /// to let us know. focused: bool = true, +/// Used to determine whether to continuously scroll. +selection_scroll_active: bool = false, + /// The effect of an input event. This can be used by callers to take /// the appropriate action after an input event. For example, key /// input can be forwarded to the OS for further processing if it @@ -237,6 +240,7 @@ const DerivedConfig = struct { /// For docs for these, see the associated config they are derived from. original_font_size: f32, keybind: configpkg.Keybinds, + abnormal_command_exit_runtime_ms: u32, clipboard_read: configpkg.ClipboardAccess, clipboard_write: configpkg.ClipboardAccess, clipboard_trim_trailing_spaces: bool, @@ -255,6 +259,7 @@ const DerivedConfig = struct { macos_option_as_alt: ?configpkg.OptionAsAlt, selection_clear_on_typing: bool, vt_kam_allowed: bool, + wait_after_command: bool, window_padding_top: u32, window_padding_bottom: u32, window_padding_left: u32, @@ -301,6 +306,7 @@ const DerivedConfig = struct { return .{ .original_font_size = config.@"font-size", .keybind = try config.keybind.clone(alloc), + .abnormal_command_exit_runtime_ms = config.@"abnormal-command-exit-runtime", .clipboard_read = config.@"clipboard-read", .clipboard_write = config.@"clipboard-write", .clipboard_trim_trailing_spaces = config.@"clipboard-trim-trailing-spaces", @@ -319,6 +325,7 @@ const DerivedConfig = struct { .macos_option_as_alt = config.@"macos-option-as-alt", .selection_clear_on_typing = config.@"selection-clear-on-typing", .vt_kam_allowed = config.@"vt-kam-allowed", + .wait_after_command = config.@"wait-after-command", .window_padding_top = config.@"window-padding-y".top_left, .window_padding_bottom = config.@"window-padding-y".bottom_right, .window_padding_left = config.@"window-padding-x".top_left, @@ -546,7 +553,7 @@ pub fn init( .shell_integration = config.@"shell-integration", .shell_integration_features = config.@"shell-integration-features", .working_directory = config.@"working-directory", - .resources_dir = global_state.resources_dir, + .resources_dir = global_state.resources_dir.host(), .term = config.term, // Get the cgroup if we're on linux and have the decl. I'd love @@ -911,11 +918,7 @@ pub fn handleMessage(self: *Surface, msg: Message) !void { .close => self.close(), - // Close without confirmation. - .child_exited => { - self.child_exited = true; - self.close(); - }, + .child_exited => |v| self.childExited(v), .desktop_notification => |notification| { if (!self.config.desktop_notifications) { @@ -945,9 +948,181 @@ pub fn handleMessage(self: *Surface, msg: Message) !void { log.warn("apprt failed to ring bell={}", .{err}); }; }, + + .selection_scroll_tick => |active| { + self.selection_scroll_active = active; + try self.selectionScrollTick(); + }, } } +fn selectionScrollTick(self: *Surface) !void { + // If we're no longer active then we don't do anything. + if (!self.selection_scroll_active) return; + + // If we don't have a left mouse button down then we + // don't do anything. + if (self.mouse.left_click_count == 0) return; + + const pos = try self.rt_surface.getCursorPos(); + const pos_vp = self.posToViewport(pos.x, pos.y); + const delta: isize = if (pos.y < 0) -1 else 1; + + // We need our locked state for the remainder + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + const t: *terminal.Terminal = self.renderer_state.terminal; + + // Scroll the viewport as required + try t.scrollViewport(.{ .delta = delta }); + + // Next, trigger our drag behavior + const pin = t.screen.pages.pin(.{ + .viewport = .{ + .x = pos_vp.x, + .y = pos_vp.y, + }, + }) orelse { + if (comptime std.debug.runtime_safety) unreachable; + return; + }; + try self.dragLeftClickSingle(pin, pos.x); + + // We modified our viewport and selection so we need to queue + // a render. + try self.queueRender(); +} + +fn childExited(self: *Surface, info: apprt.surface.Message.ChildExited) void { + // Mark our flag that we exited immediately + self.child_exited = true; + + // If our runtime was below some threshold then we assume that this + // was an abnormal exit and we show an error message. + if (info.runtime_ms <= self.config.abnormal_command_exit_runtime_ms) runtime: { + // On macOS, our exit code detection doesn't work, possibly + // because of our `login` wrapper. More investigation required. + if (comptime builtin.target.os.tag.isDarwin()) break :runtime; + + // If the exit code is 0 then we it was a good exit. + if (info.exit_code == 0) break :runtime; + log.warn("abnormal process exit detected, showing error message", .{}); + + // Update our terminal to note the abnormal exit. In the future we + // may want the apprt to handle this to show some native GUI element. + self.childExitedAbnormally(info) catch |err| { + log.err("error handling abnormal child exit err={}", .{err}); + return; + }; + + return; + } + + // We output a message so that the user knows whats going on and + // doesn't think their terminal just froze. We show this unconditionally + // on close even if `wait_after_command` is false and the surface closes + // immediately because if a user does an `undo` to restore a closed + // surface then they will see this message and know the process has + // completed. + terminal: { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + const t: *terminal.Terminal = self.renderer_state.terminal; + t.carriageReturn(); + t.linefeed() catch break :terminal; + t.printString("Process exited. Press any key to close the terminal.") catch + break :terminal; + t.modes.set(.cursor_visible, false); + } + + // Waiting after command we stop here. The terminal is updated, our + // state is updated, and now its up to the user to decide what to do. + if (self.config.wait_after_command) return; + + // If we aren't waiting after the command, then we exit immediately + // with no confirmation. + self.close(); +} + +/// Called when the child process exited abnormally. +fn childExitedAbnormally( + self: *Surface, + info: apprt.surface.Message.ChildExited, +) !void { + var arena = ArenaAllocator.init(self.alloc); + defer arena.deinit(); + const alloc = arena.allocator(); + + // Build up our command for the error message + const command = try std.mem.join(alloc, " ", switch (self.io.backend) { + .exec => |*exec| exec.subprocess.args, + }); + const runtime_str = try std.fmt.allocPrint(alloc, "{d} ms", .{info.runtime_ms}); + + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + const t: *terminal.Terminal = self.renderer_state.terminal; + + // No matter what move the cursor back to the column 0. + t.carriageReturn(); + + // Reset styles + try t.setAttribute(.{ .unset = {} }); + + // If there is data in the viewport, we want to scroll down + // a little bit and write a horizontal rule before writing + // our message. This lets the use see the error message the + // command may have output. + const viewport_str = try t.plainString(alloc); + if (viewport_str.len > 0) { + try t.linefeed(); + for (0..t.cols) |_| try t.print(0x2501); + t.carriageReturn(); + try t.linefeed(); + try t.linefeed(); + } + + // Output our error message + try t.setAttribute(.{ .@"8_fg" = .bright_red }); + try t.setAttribute(.{ .bold = {} }); + try t.printString("Ghostty failed to launch the requested command:"); + try t.setAttribute(.{ .unset = {} }); + + t.carriageReturn(); + try t.linefeed(); + try t.linefeed(); + try t.printString(command); + try t.setAttribute(.{ .unset = {} }); + + t.carriageReturn(); + try t.linefeed(); + try t.linefeed(); + try t.printString("Runtime: "); + try t.setAttribute(.{ .@"8_fg" = .red }); + try t.printString(runtime_str); + try t.setAttribute(.{ .unset = {} }); + + // We don't print this on macOS because the exit code is always 0 + // due to the way we launch the process. + if (comptime !builtin.target.os.tag.isDarwin()) { + const exit_code_str = try std.fmt.allocPrint(alloc, "{d}", .{info.exit_code}); + t.carriageReturn(); + try t.linefeed(); + try t.printString("Exit Code: "); + try t.setAttribute(.{ .@"8_fg" = .red }); + try t.printString(exit_code_str); + try t.setAttribute(.{ .unset = {} }); + } + + t.carriageReturn(); + try t.linefeed(); + try t.linefeed(); + try t.printString("Press any key to close the window."); + + // Hide the cursor + t.modes.set(.cursor_visible, false); +} + /// Called when the terminal detects there is a password input prompt. fn passwordInput(self: *Surface, v: bool) !void { { @@ -1953,6 +2128,14 @@ pub fn keyCallback( if (self.io.terminal.modes.get(.disable_keyboard)) return .consumed; } + // If our process is exited and we press a key then we close the + // surface. We may want to eventually move this to the apprt rather + // than in core. + if (self.child_exited and event.action == .press) { + self.close(); + return .closed; + } + // If this input event has text, then we hide the mouse if configured. // We only do this on pressed events to avoid hiding the mouse when we // change focus due to a keybinding (i.e. switching tabs). @@ -3094,15 +3277,42 @@ pub fn mouseButtonCallback( } } - // Handle link clicking. We want to do this before we do mouse - // reporting or any other mouse handling because a successfully - // clicked link will swallow the event. - if (button == .left and action == .release and self.mouse.over_link) { - const pos = try self.rt_surface.getCursorPos(); - if (self.processLinks(pos)) |processed| { - if (processed) return true; - } else |err| { - log.warn("error processing links err={}", .{err}); + if (button == .left and action == .release) { + // Stop selection scrolling when releasing the left mouse button + // but only when selection scrolling is active. + if (self.selection_scroll_active) { + self.io.queueMessage( + .{ .selection_scroll = false }, + .unlocked, + ); + } + + // The selection clipboard is only updated for left-click drag when + // the left button is released. This is to avoid the clipboard + // being updated on every mouse move which would be noisy. + if (self.config.copy_on_select != .false) { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + const prev_ = self.io.terminal.screen.selection; + if (prev_) |prev| { + try self.setSelection(terminal.Selection.init( + prev.start(), + prev.end(), + false, + )); + } + } + + // Handle link clicking. We want to do this before we do mouse + // reporting or any other mouse handling because a successfully + // clicked link will swallow the event. + if (self.mouse.over_link) { + const pos = try self.rt_surface.getCursorPos(); + if (self.processLinks(pos)) |processed| { + if (processed) return true; + } else |err| { + log.warn("error processing links err={}", .{err}); + } } } @@ -3238,12 +3448,16 @@ pub fn mouseButtonCallback( log.err("error reading time, mouse multi-click won't work err={}", .{err}); } + // In all cases below, we set the selection directly rather than use + // `setSelection` because we want to avoid copying the selection + // to the selection clipboard. For left mouse clicks we only set + // the clipboard on release. switch (self.mouse.left_click_count) { // Single click 1 => { // If we have a selection, clear it. This always happens. if (self.io.terminal.screen.selection != null) { - try self.setSelection(null); + try self.io.terminal.screen.select(null); try self.queueRender(); } }, @@ -3252,7 +3466,7 @@ pub fn mouseButtonCallback( 2 => { const sel_ = self.io.terminal.screen.selectWord(pin.*); if (sel_) |sel| { - try self.setSelection(sel); + try self.io.terminal.screen.select(sel); try self.queueRender(); } }, @@ -3264,7 +3478,7 @@ pub fn mouseButtonCallback( else self.io.terminal.screen.selectLine(.{ .pin = pin.* }); if (sel_) |sel| { - try self.setSelection(sel); + try self.io.terminal.screen.select(sel); try self.queueRender(); } }, @@ -3549,7 +3763,7 @@ pub fn mousePressureCallback( // to handle state inconsistency here. const pin = self.mouse.left_click_pin orelse break :select; const sel = self.io.terminal.screen.selectWord(pin.*) orelse break :select; - try self.setSelection(sel); + try self.io.terminal.screen.select(sel); try self.queueRender(); } } @@ -3626,6 +3840,15 @@ pub fn cursorPosCallback( self.renderer_state.mutex.lock(); defer self.renderer_state.mutex.unlock(); + // Stop selection scrolling when inside the viewport within a 1px buffer + // for fullscreen windows, but only when selection scrolling is active. + if (pos.x >= 1 and pos.y >= 1 and self.selection_scroll_active) { + self.io.queueMessage( + .{ .selection_scroll = false }, + .locked, + ); + } + // Update our mouse state. We set this to null initially because we only // want to set it when we're not selecting or doing any other mouse // event. @@ -3708,13 +3931,16 @@ pub fn cursorPosCallback( // Note: one day, we can change this from distance to time based if we want. //log.warn("CURSOR POS: {} {}", .{ pos, self.size.screen }); const max_y: f32 = @floatFromInt(self.size.screen.height); - if (pos.y <= 1 or pos.y > max_y - 1) { - const delta: isize = if (pos.y < 0) -1 else 1; - try self.io.terminal.scrollViewport(.{ .delta = delta }); - // TODO: We want a timer or something to repeat while we're still - // at this cursor position. Right now, the user has to jiggle their - // mouse in order to scroll. + // If the mouse is outside the viewport and we have the left + // mouse button pressed then we need to start the scroll timer. + if ((pos.y <= 1 or pos.y > max_y - 1) and + !self.selection_scroll_active) + { + self.io.queueMessage( + .{ .selection_scroll = true }, + .locked, + ); } // Convert to points @@ -3768,13 +3994,13 @@ fn dragLeftClickDouble( // If our current mouse position is before the starting position, // then the selection start is the word nearest our current position. if (drag_pin.before(click_pin)) { - try self.setSelection(terminal.Selection.init( + try self.io.terminal.screen.select(.init( word_current.start(), word_start.end(), false, )); } else { - try self.setSelection(terminal.Selection.init( + try self.io.terminal.screen.select(.init( word_start.start(), word_current.end(), false, @@ -3806,7 +4032,7 @@ fn dragLeftClickTriple( } else { sel.endPtr().* = line.end(); } - try self.setSelection(sel); + try self.io.terminal.screen.select(sel); } fn dragLeftClickSingle( @@ -3815,7 +4041,7 @@ fn dragLeftClickSingle( drag_x: f64, ) !void { // This logic is in a separate function so that it can be unit tested. - try self.setSelection(mouseSelection( + try self.io.terminal.screen.select(mouseSelection( self.mouse.left_click_pin.?.*, drag_pin, @intFromFloat(@max(0.0, self.mouse.left_click_xpos)), @@ -4685,6 +4911,11 @@ fn writeScreenFile( const path = try tmp_dir.dir.realpath(filename, &path_buf); switch (write_action) { + .copy => { + const pathZ = try self.alloc.dupeZ(u8, path); + defer self.alloc.free(pathZ); + try self.rt_surface.setClipboardString(pathZ, .standard, false); + }, .open => try internal_os.open(self.alloc, .text, path), .paste => self.io.queueMessage(try termio.Message.writeReq( self.alloc, diff --git a/src/apprt/browser.zig b/src/apprt/browser.zig index d60776a6a..3b1aa468f 100644 --- a/src/apprt/browser.zig +++ b/src/apprt/browser.zig @@ -1,2 +1,4 @@ +const internal_os = @import("../os/main.zig"); +pub const resourcesDir = internal_os.resourcesDir; pub const App = struct {}; pub const Window = struct {}; diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 77c22c7f5..dec1e4135 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -23,6 +23,8 @@ const Config = configpkg.Config; const log = std.log.scoped(.embedded_window); +pub const resourcesDir = internal_os.resourcesDir; + pub const App = struct { /// Because we only expect the embedding API to be used in embedded /// environments, the options are extern so that we can expose it @@ -115,10 +117,11 @@ pub const App = struct { config: Config, pub fn init( + self: *App, core_app: *CoreApp, config: *const Config, opts: Options, - ) !App { + ) !void { // We have to clone the config. const alloc = core_app.alloc; var config_clone = try config.clone(alloc); @@ -127,7 +130,7 @@ pub const App = struct { var keymap = try input.Keymap.init(); errdefer keymap.deinit(); - return .{ + self.* = .{ .core_app = core_app, .config = config_clone, .opts = opts, @@ -1314,13 +1317,13 @@ pub const CAPI = struct { opts: *const apprt.runtime.App.Options, config: *const Config, ) !*App { - var core_app = try CoreApp.create(global.alloc); + const core_app = try CoreApp.create(global.alloc); errdefer core_app.destroy(); // Create our runtime app var app = try global.alloc.create(App); errdefer global.alloc.destroy(app); - app.* = try .init(core_app, config, opts.*); + try app.init(core_app, config, opts.*); errdefer app.terminate(); return app; diff --git a/src/apprt/glfw.zig b/src/apprt/glfw.zig index 924737074..b82771d75 100644 --- a/src/apprt/glfw.zig +++ b/src/apprt/glfw.zig @@ -35,6 +35,8 @@ const darwin_enabled = builtin.target.os.tag.isDarwin() and const log = std.log.scoped(.glfw); +pub const resourcesDir = internal_os.resourcesDir; + pub const App = struct { app: *CoreApp, config: Config, @@ -48,7 +50,7 @@ pub const App = struct { pub const Options = struct {}; - pub fn init(core_app: *CoreApp, _: Options) !App { + pub fn init(self: *App, core_app: *CoreApp, _: Options) !void { if (comptime builtin.target.os.tag.isDarwin()) { log.warn("WARNING WARNING WARNING: GLFW ON MAC HAS BUGS.", .{}); log.warn("You should use the AppKit-based app instead. The official download", .{}); @@ -105,7 +107,7 @@ pub const App = struct { // We want the event loop to wake up instantly so we can process our tick. glfw.postEmptyEvent(); - return .{ + self.* = .{ .app = core_app, .config = config, .darwin = darwin, diff --git a/src/apprt/gtk.zig b/src/apprt/gtk.zig index 882448ed7..3193065c4 100644 --- a/src/apprt/gtk.zig +++ b/src/apprt/gtk.zig @@ -2,6 +2,7 @@ pub const App = @import("gtk/App.zig"); pub const Surface = @import("gtk/Surface.zig"); +pub const resourcesDir = @import("gtk/flatpak.zig").resourcesDir; test { @import("std").testing.refAllDecls(@This()); diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index 7c9c15191..7786f976a 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -110,7 +110,7 @@ quit_timer: union(enum) { expired: void, } = .{ .off = {} }, -pub fn init(core_app: *CoreApp, opts: Options) !App { +pub fn init(self: *App, core_app: *CoreApp, opts: Options) !void { _ = opts; // Log our GTK version @@ -405,11 +405,15 @@ pub fn init(core_app: *CoreApp, opts: Options) !App { // This just calls the `activate` signal but its part of the normal startup // routine so we just call it, but only if the config allows it (this allows // for launching Ghostty in the "background" without immediately opening - // a window) + // a window). An initial window will not be immediately created if we were + // launched by D-Bus activation or systemd. D-Bus activation will send it's + // own `activate` or `new-window` signal later. // // https://gitlab.gnome.org/GNOME/glib/-/blob/bd2ccc2f69ecfd78ca3f34ab59e42e2b462bad65/gio/gapplication.c#L2302 - if (config.@"initial-window") - gio_app.activate(); + if (config.@"initial-window") switch (config.@"launched-from".?) { + .desktop, .cli => gio_app.activate(), + .dbus, .systemd => {}, + }; // Internally, GTK ensures that only one instance of this provider exists in the provider list // for the display. @@ -420,7 +424,7 @@ pub fn init(core_app: *CoreApp, opts: Options) !App { gtk.STYLE_PROVIDER_PRIORITY_APPLICATION + 3, ); - return .{ + self.* = .{ .core_app = core_app, .app = adw_app, .config = config, @@ -1683,6 +1687,17 @@ fn gtkActionShowGTKInspector( }; } +fn gtkActionNewWindow( + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *App, +) callconv(.c) void { + log.info("received new window action", .{}); + _ = self.core_app.mailbox.push(.{ + .new_window = .{}, + }, .{ .forever = {} }); +} + /// This is called to setup the action map that this application supports. /// This should be called only once on startup. fn initActions(self: *App) void { @@ -1702,7 +1717,9 @@ fn initActions(self: *App) void { .{ "reload-config", gtkActionReloadConfig, null }, .{ "present-surface", gtkActionPresentSurface, t }, .{ "show-gtk-inspector", gtkActionShowGTKInspector, null }, + .{ "new-window", gtkActionNewWindow, null }, }; + inline for (actions) |entry| { const action = gio.SimpleAction.new(entry[0], entry[2]); defer action.unref(); diff --git a/src/apprt/gtk/ClipboardConfirmationWindow.zig b/src/apprt/gtk/ClipboardConfirmationWindow.zig index fab1aa893..bf1549021 100644 --- a/src/apprt/gtk/ClipboardConfirmationWindow.zig +++ b/src/apprt/gtk/ClipboardConfirmationWindow.zig @@ -17,7 +17,7 @@ const adw_version = @import("adw_version.zig"); const log = std.log.scoped(.gtk); -const DialogType = if (adw_version.atLeast(1, 5, 0)) adw.AlertDialog else adw.MessageDialog; +const DialogType = if (adw_version.supportsDialogs()) adw.AlertDialog else adw.MessageDialog; app: *App, dialog: *DialogType, @@ -28,6 +28,7 @@ text_view: *gtk.TextView, text_view_scroll: *gtk.ScrolledWindow, reveal_button: *gtk.Button, hide_button: *gtk.Button, +remember_choice: if (adw_version.supportsSwitchRow()) ?*adw.SwitchRow else ?*anyopaque, pub fn create( app: *App, @@ -89,6 +90,10 @@ fn init( const reveal_button = builder.getObject(gtk.Button, "reveal_button").?; const hide_button = builder.getObject(gtk.Button, "hide_button").?; const text_view_scroll = builder.getObject(gtk.ScrolledWindow, "text_view_scroll").?; + const remember_choice = if (adw_version.supportsSwitchRow()) + builder.getObject(adw.SwitchRow, "remember_choice") + else + null; const copy = try app.core_app.alloc.dupeZ(u8, data); errdefer app.core_app.alloc.free(copy); @@ -102,6 +107,7 @@ fn init( .text_view_scroll = text_view_scroll, .reveal_button = reveal_button, .hide_button = hide_button, + .remember_choice = remember_choice, }; const buffer = gtk.TextBuffer.new(null); @@ -152,8 +158,10 @@ fn init( } } -fn gtkResponse(_: *DialogType, response: [*:0]u8, self: *ClipboardConfirmation) callconv(.c) void { - if (std.mem.orderZ(u8, response, "ok") == .eq) { +fn handleResponse(self: *ClipboardConfirmation, response: [*:0]const u8) void { + const is_ok = std.mem.orderZ(u8, response, "ok") == .eq; + + if (is_ok) { self.core_surface.completeClipboardRequest( self.pending_req, self.data, @@ -162,8 +170,30 @@ fn gtkResponse(_: *DialogType, response: [*:0]u8, self: *ClipboardConfirmation) log.err("Failed to requeue clipboard request: {}", .{err}); }; } + + if (self.remember_choice) |remember| remember: { + if (!adw_version.supportsSwitchRow()) break :remember; + if (remember.getActive() == 0) break :remember; + + switch (self.pending_req) { + .osc_52_read => self.core_surface.config.clipboard_read = if (is_ok) .allow else .deny, + .osc_52_write => self.core_surface.config.clipboard_write = if (is_ok) .allow else .deny, + .paste => {}, + } + } + self.destroy(); } +fn gtkChoose(dialog_: ?*gobject.Object, result: *gio.AsyncResult, ud: ?*anyopaque) callconv(.C) void { + const dialog = gobject.ext.cast(DialogType, dialog_.?).?; + const self: *ClipboardConfirmation = @ptrCast(@alignCast(ud.?)); + const response = dialog.chooseFinish(result); + self.handleResponse(response); +} + +fn gtkResponse(_: *DialogType, response: [*:0]u8, self: *ClipboardConfirmation) callconv(.C) void { + self.handleResponse(response); +} fn gtkRevealButtonClicked(_: *gtk.Button, self: *ClipboardConfirmation) callconv(.c) void { self.text_view_scroll.as(gtk.Widget).setSensitive(@intFromBool(true)); diff --git a/src/apprt/gtk/CommandPalette.zig b/src/apprt/gtk/CommandPalette.zig index a99db78d7..d05f195b3 100644 --- a/src/apprt/gtk/CommandPalette.zig +++ b/src/apprt/gtk/CommandPalette.zig @@ -94,9 +94,8 @@ pub fn deinit(self: *CommandPalette) void { pub fn toggle(self: *CommandPalette) void { self.dialog.present(self.window.window.as(gtk.Widget)); - // Focus on the search bar when opening the dialog - self.dialog.setFocus(self.search.as(gtk.Widget)); + _ = self.search.as(gtk.Widget).grabFocus(); } pub fn updateConfig(self: *CommandPalette, config: *const configpkg.Config) !void { @@ -104,13 +103,17 @@ pub fn updateConfig(self: *CommandPalette, config: *const configpkg.Config) !voi self.source.removeAll(); _ = self.arena.reset(.retain_capacity); - // TODO: Allow user-configured palette entries - for (inputpkg.command.defaults) |command| { + for (config.@"command-palette-entry".value.items) |command| { // Filter out actions that are not implemented // or don't make sense for GTK switch (command.action) { .close_all_windows, .toggle_secure_input, + .check_for_updates, + .redo, + .undo, + .reset_window_size, + .toggle_window_float_on_top, => continue, else => {}, diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index cf8d651dd..5c886e663 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -2325,6 +2325,15 @@ pub fn defaultTermioEnv(self: *Surface) !std.process.EnvMap { env.remove("GDK_DISABLE"); env.remove("GSK_RENDERER"); + // Remove some environment variables that are set when Ghostty is launched + // from a `.desktop` file, by D-Bus activation, or systemd. + env.remove("GIO_LAUNCHED_DESKTOP_FILE"); + env.remove("GIO_LAUNCHED_DESKTOP_FILE_PID"); + env.remove("DBUS_STARTER_ADDRESS"); + env.remove("DBUS_STARTER_BUS_TYPE"); + env.remove("INVOCATION_ID"); + env.remove("JOURNAL_STREAM"); + // Unset environment varies set by snaps if we're running in a snap. // This allows Ghostty to further launch additional snaps. if (env.get("SNAP")) |_| { diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index f911ccbc1..555edb1e4 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -55,6 +55,9 @@ window: *adw.ApplicationWindow, /// The header bar for the window. headerbar: HeaderBar, +/// The tab bar for the window. +tab_bar: *adw.TabBar, + /// The tab overview for the window. This is possibly null since there is no /// taboverview without a AdwApplicationWindow (libadwaita >= 1.4.0). tab_overview: ?*adw.TabOverview, @@ -86,6 +89,7 @@ pub const DerivedConfig = struct { gtk_tabs_location: configpkg.Config.GtkTabsLocation, gtk_wide_tabs: bool, gtk_toolbar_style: configpkg.Config.GtkToolbarStyle, + window_show_tab_bar: configpkg.Config.WindowShowTabBar, quick_terminal_position: configpkg.Config.QuickTerminalPosition, quick_terminal_size: configpkg.Config.QuickTerminalSize, @@ -106,6 +110,7 @@ pub const DerivedConfig = struct { .gtk_tabs_location = config.@"gtk-tabs-location", .gtk_wide_tabs = config.@"gtk-wide-tabs", .gtk_toolbar_style = config.@"gtk-toolbar-style", + .window_show_tab_bar = config.@"window-show-tab-bar", .quick_terminal_position = config.@"quick-terminal-position", .quick_terminal_size = config.@"quick-terminal-size", @@ -141,6 +146,7 @@ pub fn init(self: *Window, app: *App) !void { .config = .init(&app.config), .window = undefined, .headerbar = undefined, + .tab_bar = undefined, .tab_overview = null, .notebook = undefined, .titlebar_menu = undefined, @@ -225,8 +231,9 @@ pub fn init(self: *Window, app: *App) !void { // If we're using an AdwWindow then we can support the tab overview. if (self.tab_overview) |tab_overview| { if (!adw_version.supportsTabOverview()) unreachable; - const btn = switch (self.config.gtk_tabs_location) { - .top, .bottom => btn: { + + const btn = switch (self.config.window_show_tab_bar) { + .always, .auto => btn: { const btn = gtk.ToggleButton.new(); btn.as(gtk.Widget).setTooltipText(i18n._("View Open Tabs")); btn.as(gtk.Button).setIconName("view-grid-symbolic"); @@ -238,8 +245,7 @@ pub fn init(self: *Window, app: *App) !void { ); break :btn btn.as(gtk.Widget); }, - - .hidden => btn: { + .never => btn: { const btn = adw.TabButton.new(); btn.setView(self.notebook.tab_view); btn.as(gtk.Actionable).setActionName("overview.open"); @@ -385,21 +391,16 @@ pub fn init(self: *Window, app: *App) !void { // Our actions for the menu initActions(self); + self.tab_bar = adw.TabBar.new(); + self.tab_bar.setView(self.notebook.tab_view); + if (adw_version.supportsToolbarView()) { const toolbar_view = adw.ToolbarView.new(); toolbar_view.addTopBar(self.headerbar.asWidget()); - if (self.config.gtk_tabs_location != .hidden) { - const tab_bar = adw.TabBar.new(); - tab_bar.setView(self.notebook.tab_view); - - if (!self.config.gtk_wide_tabs) tab_bar.setExpandTabs(0); - - switch (self.config.gtk_tabs_location) { - .top => toolbar_view.addTopBar(tab_bar.as(gtk.Widget)), - .bottom => toolbar_view.addBottomBar(tab_bar.as(gtk.Widget)), - .hidden => unreachable, - } + switch (self.config.gtk_tabs_location) { + .top => toolbar_view.addTopBar(self.tab_bar.as(gtk.Widget)), + .bottom => toolbar_view.addBottomBar(self.tab_bar.as(gtk.Widget)), } toolbar_view.setContent(box.as(gtk.Widget)); @@ -414,23 +415,18 @@ pub fn init(self: *Window, app: *App) !void { // Set our application window content. self.tab_overview.?.setChild(toolbar_view.as(gtk.Widget)); self.window.setContent(self.tab_overview.?.as(gtk.Widget)); - } else tab_bar: { - if (self.config.gtk_tabs_location == .hidden) break :tab_bar; + } else { // In earlier adwaita versions, we need to add the tabbar manually since we do not use // an AdwToolbarView. - const tab_bar = adw.TabBar.new(); - tab_bar.as(gtk.Widget).addCssClass("inline"); + self.tab_bar.as(gtk.Widget).addCssClass("inline"); + switch (self.config.gtk_tabs_location) { .top => box.insertChildAfter( - tab_bar.as(gtk.Widget), + self.tab_bar.as(gtk.Widget), self.headerbar.asWidget(), ), - .bottom => box.append(tab_bar.as(gtk.Widget)), - .hidden => unreachable, + .bottom => box.append(self.tab_bar.as(gtk.Widget)), } - tab_bar.setView(self.notebook.tab_view); - - if (!self.config.gtk_wide_tabs) tab_bar.setExpandTabs(0); } // If we want the window to be maximized, we do that here. @@ -555,6 +551,16 @@ pub fn syncAppearance(self: *Window) !void { } } + self.tab_bar.setExpandTabs(@intFromBool(self.config.gtk_wide_tabs)); + self.tab_bar.setAutohide(switch (self.config.window_show_tab_bar) { + .auto, .never => @intFromBool(true), + .always => @intFromBool(false), + }); + self.tab_bar.as(gtk.Widget).setVisible(switch (self.config.window_show_tab_bar) { + .always, .auto => @intFromBool(true), + .never => @intFromBool(false), + }); + self.winproto.syncAppearance() catch |err| { log.warn("failed to sync winproto appearance error={}", .{err}); }; diff --git a/src/apprt/gtk/adw_version.zig b/src/apprt/gtk/adw_version.zig index ff7439a21..7ce88f585 100644 --- a/src/apprt/gtk/adw_version.zig +++ b/src/apprt/gtk/adw_version.zig @@ -109,6 +109,10 @@ pub inline fn supportsTabOverview() bool { return atLeast(1, 4, 0); } +pub inline fn supportsSwitchRow() bool { + return atLeast(1, 4, 0); +} + pub inline fn supportsToolbarView() bool { return atLeast(1, 4, 0); } diff --git a/src/apprt/gtk/flatpak.zig b/src/apprt/gtk/flatpak.zig new file mode 100644 index 000000000..dc47c671b --- /dev/null +++ b/src/apprt/gtk/flatpak.zig @@ -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); +} diff --git a/src/apprt/gtk/style.css b/src/apprt/gtk/style.css index 7c4b53d03..2051ab1e3 100644 --- a/src/apprt/gtk/style.css +++ b/src/apprt/gtk/style.css @@ -64,14 +64,18 @@ window.ssd.no-border-radius { padding: 0; } +.clipboard-overlay { + border-radius: 10px; +} + .clipboard-content-view { filter: blur(0px); transition: filter 0.3s ease; + border-radius: 10px; } .clipboard-content-view.blurred { filter: blur(5px); - transition: filter 0.3s ease; } .command-palette-search { diff --git a/src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp b/src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp index 640556535..ad0b5c01f 100644 --- a/src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp +++ b/src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp @@ -14,58 +14,72 @@ Adw.AlertDialog clipboard_confirmation_window { default-response: "cancel"; close-response: "cancel"; - extra-child: Overlay { + extra-child: ListBox { + selection-mode: none; + styles [ - "osd", + "boxed-list-separate", ] - ScrolledWindow text_view_scroll { - width-request: 500; - height-request: 250; - - TextView text_view { - cursor-visible: false; - editable: false; - monospace: true; - top-margin: 8; - left-margin: 8; - bottom-margin: 8; - right-margin: 8; - - styles [ - "clipboard-content-view", - ] - } - } - - [overlay] - Button reveal_button { - visible: false; - halign: end; - valign: start; - margin-end: 12; - margin-top: 12; - - Image { - icon-name: "view-reveal-symbolic"; - } - } - - [overlay] - Button hide_button { - visible: false; - halign: end; - valign: start; - margin-end: 12; - margin-top: 12; - + Overlay { styles [ - "opaque", + "osd", + "clipboard-overlay", ] - Image { - icon-name: "view-conceal-symbolic"; + ScrolledWindow text_view_scroll { + width-request: 500; + height-request: 200; + + TextView text_view { + cursor-visible: false; + editable: false; + monospace: true; + top-margin: 8; + left-margin: 8; + bottom-margin: 8; + right-margin: 8; + + styles [ + "clipboard-content-view", + ] + } } + + [overlay] + Button reveal_button { + visible: false; + halign: end; + valign: start; + margin-end: 12; + margin-top: 12; + + Image { + icon-name: "view-reveal-symbolic"; + } + } + + [overlay] + Button hide_button { + visible: false; + halign: end; + valign: start; + margin-end: 12; + margin-top: 12; + + styles [ + "opaque", + ] + + Image { + icon-name: "view-conceal-symbolic"; + } + } + } + + Adw.SwitchRow remember_choice { + title: _("Remember choice for this split"); + subtitle: _("Reload configuration to show this prompt again"); } }; } diff --git a/src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp b/src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp index 2e28359ff..b71131940 100644 --- a/src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp +++ b/src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp @@ -14,58 +14,68 @@ Adw.AlertDialog clipboard_confirmation_window { default-response: "cancel"; close-response: "cancel"; - extra-child: Overlay { + extra-child: ListBox { + selection-mode: none; + styles [ - "osd", + "boxed-list-separate", ] - ScrolledWindow text_view_scroll { - width-request: 500; - height-request: 250; + Overlay { + styles [ + "osd", + "clipboard-overlay", + ] - TextView text_view { - cursor-visible: false; - editable: false; - monospace: true; - top-margin: 8; - left-margin: 8; - bottom-margin: 8; - right-margin: 8; + ScrolledWindow text_view_scroll { + width-request: 500; + height-request: 200; + + TextView text_view { + cursor-visible: false; + editable: false; + monospace: true; + top-margin: 8; + left-margin: 8; + bottom-margin: 8; + right-margin: 8; + + styles [ + "clipboard-content-view", + ] + } + } + + [overlay] + Button reveal_button { + visible: false; + halign: end; + valign: start; + margin-end: 12; + margin-top: 12; + + Image { + icon-name: "view-reveal-symbolic"; + } + } + + [overlay] + Button hide_button { + visible: false; + halign: end; + valign: start; + margin-end: 12; + margin-top: 12; styles [ - "clipboard-content-view", + "opaque", ] } } - [overlay] - Button reveal_button { - visible: false; - halign: end; - valign: start; - margin-end: 12; - margin-top: 12; - - Image { - icon-name: "view-reveal-symbolic"; - } - } - - [overlay] - Button hide_button { - visible: false; - halign: end; - valign: start; - margin-end: 12; - margin-top: 12; - - styles [ - "opaque", - ] - - Image { - icon-name: "view-conceal-symbolic"; - } + Adw.SwitchRow remember_choice { + title: _("Remember choice for this split"); + subtitle: _("Reload configuration to show this prompt again"); } }; } diff --git a/src/apprt/gtk/winproto/wayland.zig b/src/apprt/gtk/winproto/wayland.zig index cbe8c01a4..ae3c871f2 100644 --- a/src/apprt/gtk/winproto/wayland.zig +++ b/src/apprt/gtk/winproto/wayland.zig @@ -37,6 +37,19 @@ pub const App = struct { default_deco_mode: ?org.KdeKwinServerDecorationManager.Mode = null, xdg_activation: ?*xdg.ActivationV1 = null, + + /// Whether the xdg_wm_dialog_v1 protocol is present. + /// + /// If it is present, gtk4-layer-shell < 1.0.4 may crash when the user + /// creates a quick terminal, and we need to ensure this fails + /// gracefully if this situation occurs. + /// + /// FIXME: This is a temporary workaround - we should remove this when + /// all of our supported distros drop support for affected old + /// gtk4-layer-shell versions. + /// + /// See https://github.com/wmww/gtk4-layer-shell/issues/50 + xdg_wm_dialog_present: bool = false, }; pub fn init( @@ -95,11 +108,21 @@ pub const App = struct { return null; } - pub fn supportsQuickTerminal(_: App) bool { + pub fn supportsQuickTerminal(self: App) bool { if (!layer_shell.isSupported()) { log.warn("your compositor does not support the wlr-layer-shell protocol; disabling quick terminal", .{}); return false; } + + if (self.context.xdg_wm_dialog_present and layer_shell.getLibraryVersion().order(.{ + .major = 1, + .minor = 0, + .patch = 4, + }) == .lt) { + log.warn("the version of gtk4-layer-shell installed on your system is too old (must be 1.0.4 or newer); disabling quick terminal", .{}); + return false; + } + return true; } @@ -111,26 +134,38 @@ pub const App = struct { layer_shell.setNamespace(window, "ghostty-quick-terminal"); } + fn getInterfaceType(comptime field: std.builtin.Type.StructField) ?type { + // Globals should be optional pointers + const T = switch (@typeInfo(field.type)) { + .optional => |o| switch (@typeInfo(o.child)) { + .pointer => |v| v.child, + else => return null, + }, + else => return null, + }; + + // Only process Wayland interfaces + if (!@hasDecl(T, "interface")) return null; + return T; + } + fn registryListener( registry: *wl.Registry, event: wl.Registry.Event, context: *Context, ) void { - inline for (@typeInfo(Context).@"struct".fields) |field| { - // Globals should be optional pointers - const T = switch (@typeInfo(field.type)) { - .optional => |o| switch (@typeInfo(o.child)) { - .pointer => |v| v.child, - else => continue, - }, - else => continue, - }; + const ctx_fields = @typeInfo(Context).@"struct".fields; - // Only process Wayland interfaces - if (!@hasDecl(T, "interface")) continue; + switch (event) { + .global => |v| global: { + // We don't actually do anything with this other than checking + // for its existence, so we process this separately. + if (std.mem.orderZ(u8, v.interface, "xdg_wm_dialog_v1") == .eq) + context.xdg_wm_dialog_present = true; + + inline for (ctx_fields) |field| { + const T = getInterfaceType(field) orelse continue; - switch (event) { - .global => |v| global: { if (std.mem.orderZ( u8, v.interface, @@ -148,19 +183,22 @@ pub const App = struct { ); return; }; - }, + } + }, - // This should be a rare occurrence, but in case a global - // is suddenly no longer available, we destroy and unset it - // as the protocol mandates. - .global_remove => |v| remove: { + // This should be a rare occurrence, but in case a global + // is suddenly no longer available, we destroy and unset it + // as the protocol mandates. + .global_remove => |v| remove: { + inline for (ctx_fields) |field| { + if (getInterfaceType(field) == null) continue; const global = @field(context, field.name) orelse break :remove; if (global.getId() == v.name) { global.destroy(); @field(context, field.name) = null; } - }, - } + } + }, } } diff --git a/src/apprt/none.zig b/src/apprt/none.zig index 76a0a8ecb..76faa88af 100644 --- a/src/apprt/none.zig +++ b/src/apprt/none.zig @@ -1,2 +1,4 @@ +const internal_os = @import("../os/main.zig"); +pub const resourcesDir = internal_os.resourcesDir; pub const App = struct {}; pub const Surface = struct {}; diff --git a/src/apprt/surface.zig b/src/apprt/surface.zig index dce6a3a56..9254b2fd5 100644 --- a/src/apprt/surface.zig +++ b/src/apprt/surface.zig @@ -43,8 +43,9 @@ pub const Message = union(enum) { close: void, /// The child process running in the surface has exited. This may trigger - /// a surface close, it may not. - child_exited: void, + /// a surface close, it may not. Additional details about the child + /// command are given in the `ChildExited` struct. + child_exited: ChildExited, /// Show a desktop notification. desktop_notification: struct { @@ -78,6 +79,13 @@ pub const Message = union(enum) { color: terminal.color.RGB, }, + /// Notifies the surface that a tick of the timer that is timing + /// out selection scrolling has occurred. "selection scrolling" + /// is when the user has clicked and dragged the mouse outside + /// the viewport of the terminal and the terminal is scrolling + /// the viewport to follow the mouse cursor. + selection_scroll_tick: bool, + /// The terminal has reported a change in the working directory. pwd_change: WriteReq, @@ -89,6 +97,11 @@ pub const Message = union(enum) { // This enum is a placeholder for future title styles. }; + + pub const ChildExited = struct { + exit_code: u32, + runtime_ms: u64, + }; }; /// A surface mailbox. diff --git a/src/build/Config.zig b/src/build/Config.zig index 8974e1f0c..5f8780af9 100644 --- a/src/build/Config.zig +++ b/src/build/Config.zig @@ -87,7 +87,7 @@ pub fn init(b: *std.Build) !Config { // This is set to true when we're building a system package. For now // this is trivially detected using the "system_package_mode" bool // but we may want to make this more sophisticated in the future. - const system_package: bool = b.graph.system_package_mode; + const system_package = b.graph.system_package_mode; // This specifies our target wasm runtime. For now only one semi-usable // one exists so this is hardcoded. @@ -361,7 +361,6 @@ pub fn init(b: *std.Build) !Config { "libpng", "zlib", "oniguruma", - "gtk4-layer-shell", }) |dep| { _ = b.systemIntegrationOption( dep, @@ -387,6 +386,15 @@ pub fn init(b: *std.Build) !Config { }) |dep| { _ = b.systemIntegrationOption(dep, .{ .default = false }); } + + // These are dynamic libraries we default to true, preferring + // to use system packages over building and installing libs + // as they require additional ldconfig of library paths or + // patching the rpath of the program to discover the dynamic library + // at runtime + for (&[_][]const u8{"gtk4-layer-shell"}) |dep| { + _ = b.systemIntegrationOption(dep, .{ .default = true }); + } } return config; diff --git a/src/build/GhosttyResources.zig b/src/build/GhosttyResources.zig index 640491fd6..34b5e35f8 100644 --- a/src/build/GhosttyResources.zig +++ b/src/build/GhosttyResources.zig @@ -2,6 +2,7 @@ const GhosttyResources = @This(); const std = @import("std"); const builtin = @import("builtin"); +const assert = std.debug.assert; const buildpkg = @import("main.zig"); const Config = @import("Config.zig"); const config_vim = @import("../config/vim.zig"); @@ -220,83 +221,178 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyResources { } // App (Linux) - if (cfg.target.result.os.tag == .linux) { - // https://developer.gnome.org/documentation/guidelines/maintainer/integrating.html + if (cfg.target.result.os.tag == .linux) try addLinuxAppResources( + b, + cfg, + &steps, + ); + + return .{ .steps = steps.items }; +} + +/// Add the resource files needed to make Ghostty a proper +/// Linux desktop application (for various desktop environments). +fn addLinuxAppResources( + b: *std.Build, + cfg: *const Config, + steps: *std.ArrayList(*std.Build.Step), +) !void { + assert(cfg.target.result.os.tag == .linux); + + // Background: + // https://developer.gnome.org/documentation/guidelines/maintainer/integrating.html + + const name = b.fmt("Ghostty{s}", .{ + switch (cfg.optimize) { + .Debug, .ReleaseSafe => " (Debug)", + .ReleaseFast, .ReleaseSmall => "", + }, + }); + + const app_id = b.fmt("com.mitchellh.ghostty{s}", .{ + switch (cfg.optimize) { + .Debug, .ReleaseSafe => "-debug", + .ReleaseFast, .ReleaseSmall => "", + }, + }); + + const exe_abs_path = b.fmt( + "{s}/bin/ghostty", + .{b.install_prefix}, + ); + + // The templates that we will process. The templates are in + // cmake format and will be processed and saved to the + // second element of the tuple. + const Template = struct { std.Build.LazyPath, []const u8 }; + const templates: []const Template = templates: { + var ts: std.ArrayList(Template) = .init(b.allocator); // Desktop file so that we have an icon and other metadata - try steps.append(&b.addInstallFile( - b.path("dist/linux/app.desktop"), - "share/applications/com.mitchellh.ghostty.desktop", - ).step); + try ts.append(.{ + b.path("dist/linux/app.desktop.in"), + b.fmt("share/applications/{s}.desktop", .{app_id}), + }); - // AppStream metainfo so that application has rich metadata within app stores - try steps.append(&b.addInstallFile( - b.path("dist/linux/com.mitchellh.ghostty.metainfo.xml"), - "share/metainfo/com.mitchellh.ghostty.metainfo.xml", - ).step); + // Service for DBus activation. + try ts.append(.{ + if (cfg.flatpak) + b.path("dist/linux/dbus.service.flatpak.in") + else + b.path("dist/linux/dbus.service.in"), + b.fmt("share/dbus-1/services/{s}.service", .{app_id}), + }); - // Right click menu action for Plasma desktop - try steps.append(&b.addInstallFile( - b.path("dist/linux/ghostty_dolphin.desktop"), - "share/kio/servicemenus/com.mitchellh.ghostty.desktop", - ).step); + // systemd user service. This is kind of nasty but systemd + // looks for user services in different paths depending on + // if we are installed as a system package or not (lib vs. + // share) so we have to handle that here. We might be able + // to get away with always installing to both because it + // only ever searches in one... but I don't want to do that hack + // until we have to. + if (!cfg.flatpak) try ts.append(.{ + b.path("dist/linux/systemd.service.in"), + b.fmt( + "{s}/systemd/user/{s}.service", + .{ + if (b.graph.system_package_mode) "lib" else "share", + app_id, + }, + ), + }); - // Right click menu action for Nautilus. Note that this _must_ be named - // `ghostty.py`. Using the full app id causes problems (see #5468). - try steps.append(&b.addInstallFile( - b.path("dist/linux/ghostty_nautilus.py"), - "share/nautilus-python/extensions/ghostty.py", - ).step); + // AppStream metainfo so that application has rich metadata + // within app stores + try ts.append(.{ + b.path("dist/linux/com.mitchellh.ghostty.metainfo.xml.in"), + b.fmt("share/metainfo/{s}.metainfo.xml", .{app_id}), + }); - // Various icons that our application can use, including the icon - // that will be used for the desktop. - try steps.append(&b.addInstallFile( - b.path("images/icons/icon_16.png"), - "share/icons/hicolor/16x16/apps/com.mitchellh.ghostty.png", - ).step); - try steps.append(&b.addInstallFile( - b.path("images/icons/icon_32.png"), - "share/icons/hicolor/32x32/apps/com.mitchellh.ghostty.png", - ).step); - try steps.append(&b.addInstallFile( - b.path("images/icons/icon_128.png"), - "share/icons/hicolor/128x128/apps/com.mitchellh.ghostty.png", - ).step); - try steps.append(&b.addInstallFile( - b.path("images/icons/icon_256.png"), - "share/icons/hicolor/256x256/apps/com.mitchellh.ghostty.png", - ).step); - try steps.append(&b.addInstallFile( - b.path("images/icons/icon_512.png"), - "share/icons/hicolor/512x512/apps/com.mitchellh.ghostty.png", - ).step); - // Flatpaks only support icons up to 512x512. - if (!cfg.flatpak) { - try steps.append(&b.addInstallFile( - b.path("images/icons/icon_1024.png"), - "share/icons/hicolor/1024x1024/apps/com.mitchellh.ghostty.png", - ).step); - } + break :templates ts.items; + }; + // Process all our templates + for (templates) |template| { + const tpl = b.addConfigHeader(.{ + .style = .{ .cmake = template[0] }, + }, .{ + .NAME = name, + .APPID = app_id, + .GHOSTTY = exe_abs_path, + }); + + // Template output has a single header line we want to remove. + // We use `tail` to do it since its part of the POSIX standard. + const tail = b.addSystemCommand(&.{ "tail", "-n", "+2" }); + tail.setStdIn(.{ .lazy_path = tpl.getOutput() }); + + const copy = b.addInstallFile( + tail.captureStdOut(), + template[1], + ); + + try steps.append(©.step); + } + + // Right click menu action for Plasma desktop + try steps.append(&b.addInstallFile( + b.path("dist/linux/ghostty_dolphin.desktop"), + "share/kio/servicemenus/com.mitchellh.ghostty.desktop", + ).step); + + // Right click menu action for Nautilus. Note that this _must_ be named + // `ghostty.py`. Using the full app id causes problems (see #5468). + try steps.append(&b.addInstallFile( + b.path("dist/linux/ghostty_nautilus.py"), + "share/nautilus-python/extensions/ghostty.py", + ).step); + + // Various icons that our application can use, including the icon + // that will be used for the desktop. + try steps.append(&b.addInstallFile( + b.path("images/icons/icon_16.png"), + "share/icons/hicolor/16x16/apps/com.mitchellh.ghostty.png", + ).step); + try steps.append(&b.addInstallFile( + b.path("images/icons/icon_32.png"), + "share/icons/hicolor/32x32/apps/com.mitchellh.ghostty.png", + ).step); + try steps.append(&b.addInstallFile( + b.path("images/icons/icon_128.png"), + "share/icons/hicolor/128x128/apps/com.mitchellh.ghostty.png", + ).step); + try steps.append(&b.addInstallFile( + b.path("images/icons/icon_256.png"), + "share/icons/hicolor/256x256/apps/com.mitchellh.ghostty.png", + ).step); + try steps.append(&b.addInstallFile( + b.path("images/icons/icon_512.png"), + "share/icons/hicolor/512x512/apps/com.mitchellh.ghostty.png", + ).step); + // Flatpaks only support icons up to 512x512. + if (!cfg.flatpak) { try steps.append(&b.addInstallFile( - b.path("images/icons/icon_16@2x.png"), - "share/icons/hicolor/16x16@2/apps/com.mitchellh.ghostty.png", - ).step); - try steps.append(&b.addInstallFile( - b.path("images/icons/icon_32@2x.png"), - "share/icons/hicolor/32x32@2/apps/com.mitchellh.ghostty.png", - ).step); - try steps.append(&b.addInstallFile( - b.path("images/icons/icon_128@2x.png"), - "share/icons/hicolor/128x128@2/apps/com.mitchellh.ghostty.png", - ).step); - try steps.append(&b.addInstallFile( - b.path("images/icons/icon_256@2x.png"), - "share/icons/hicolor/256x256@2/apps/com.mitchellh.ghostty.png", + b.path("images/icons/icon_1024.png"), + "share/icons/hicolor/1024x1024/apps/com.mitchellh.ghostty.png", ).step); } - return .{ .steps = steps.items }; + try steps.append(&b.addInstallFile( + b.path("images/icons/icon_16@2x.png"), + "share/icons/hicolor/16x16@2/apps/com.mitchellh.ghostty.png", + ).step); + try steps.append(&b.addInstallFile( + b.path("images/icons/icon_32@2x.png"), + "share/icons/hicolor/32x32@2/apps/com.mitchellh.ghostty.png", + ).step); + try steps.append(&b.addInstallFile( + b.path("images/icons/icon_128@2x.png"), + "share/icons/hicolor/128x128@2/apps/com.mitchellh.ghostty.png", + ).step); + try steps.append(&b.addInstallFile( + b.path("images/icons/icon_256@2x.png"), + "share/icons/hicolor/256x256@2/apps/com.mitchellh.ghostty.png", + ).step); } pub fn install(self: *const GhosttyResources) void { diff --git a/src/build/SharedDeps.zig b/src/build/SharedDeps.zig index 5d737cb6f..ec97a9c9f 100644 --- a/src/build/SharedDeps.zig +++ b/src/build/SharedDeps.zig @@ -75,7 +75,7 @@ fn initTarget( self.metallib = .create(b, .{ .name = "Ghostty", .target = target, - .sources = &.{b.path("src/renderer/shaders/cell.metal")}, + .sources = &.{b.path("src/renderer/shaders/shaders.metal")}, }); // Change our config @@ -652,14 +652,13 @@ fn addGTK( // IMPORTANT: gtk4-layer-shell must be linked BEFORE // wayland-client, as it relies on shimming libwayland's APIs. if (b.systemIntegrationOption("gtk4-layer-shell", .{})) { - step.linkSystemLibrary2( - "gtk4-layer-shell-0", - dynamic_link_opts, - ); + step.linkSystemLibrary2("gtk4-layer-shell-0", dynamic_link_opts); } else { // gtk4-layer-shell *must* be dynamically linked, // so we don't add it as a static library - step.linkLibrary(gtk4_layer_shell.artifact("gtk4-layer-shell")); + const shared_lib = gtk4_layer_shell.artifact("gtk4-layer-shell"); + b.installArtifact(shared_lib); + step.linkLibrary(shared_lib); } } diff --git a/src/cli.zig b/src/cli.zig index 4336501a8..151e6e648 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -2,6 +2,8 @@ const diags = @import("cli/diagnostics.zig"); pub const args = @import("cli/args.zig"); pub const Action = @import("cli/action.zig").Action; +pub const CompatibilityHandler = args.CompatibilityHandler; +pub const compatibilityRenamed = args.compatibilityRenamed; pub const DiagnosticList = diags.DiagnosticList; pub const Diagnostic = diags.Diagnostic; pub const Location = diags.Location; diff --git a/src/cli/action.zig b/src/cli/action.zig index a53e55ef8..009afb4c9 100644 --- a/src/cli/action.zig +++ b/src/cli/action.zig @@ -9,6 +9,7 @@ const list_keybinds = @import("list_keybinds.zig"); const list_themes = @import("list_themes.zig"); const list_colors = @import("list_colors.zig"); const list_actions = @import("list_actions.zig"); +const edit_config = @import("edit_config.zig"); const show_config = @import("show_config.zig"); const validate_config = @import("validate_config.zig"); const crash_report = @import("crash_report.zig"); @@ -40,6 +41,9 @@ pub const Action = enum { /// List keybind actions @"list-actions", + /// Edit the config file in the configured terminal editor. + @"edit-config", + /// Dump the config to stdout @"show-config", @@ -151,6 +155,7 @@ pub const Action = enum { .@"list-themes" => try list_themes.run(alloc), .@"list-colors" => try list_colors.run(alloc), .@"list-actions" => try list_actions.run(alloc), + .@"edit-config" => try edit_config.run(alloc), .@"show-config" => try show_config.run(alloc), .@"validate-config" => try validate_config.run(alloc), .@"crash-report" => try crash_report.run(alloc), @@ -187,6 +192,7 @@ pub const Action = enum { .@"list-themes" => list_themes.Options, .@"list-colors" => list_colors.Options, .@"list-actions" => list_actions.Options, + .@"edit-config" => edit_config.Options, .@"show-config" => show_config.Options, .@"validate-config" => validate_config.Options, .@"crash-report" => crash_report.Options, diff --git a/src/cli/args.zig b/src/cli/args.zig index 3c34e17fe..1af74df69 100644 --- a/src/cli/args.zig +++ b/src/cli/args.zig @@ -40,11 +40,14 @@ pub const Error = error{ /// "DiagnosticList" and any diagnostic messages will be added to that list. /// When diagnostics are present, only allocation errors will be returned. /// -/// If the destination type has a decl "renamed", it must be of type -/// std.StaticStringMap([]const u8) and contains a mapping from the old -/// field name to the new field name. This is used to allow renaming fields -/// while still supporting the old name. If a renamed field is set, parsing -/// will automatically set the new field name. +/// If the destination type has a decl "compatibility", it must be of type +/// std.StaticStringMap(CompatibilityHandler(T)), and it will be used to +/// handle backwards compatibility for fields with the given name. The +/// field name doesn't need to exist (so you can setup compatibility for +/// removed fields). The value is a function that will be called when +/// all other parsing fails for that field. If a field changes such that +/// the old values would NOT error, then the caller should handle that +/// downstream after parsing is done, not through this method. /// /// Note: If the arena is already non-null, then it will be used. In this /// case, in the case of an error some memory might be leaked into the arena. @@ -57,24 +60,6 @@ pub fn parse( const info = @typeInfo(T); assert(info == .@"struct"); - comptime { - // Verify all renamed fields are valid (source does not exist, - // destination does exist). - if (@hasDecl(T, "renamed")) { - for (T.renamed.keys(), T.renamed.values()) |key, value| { - if (@hasField(T, key)) { - @compileLog(key); - @compileError("renamed field source exists"); - } - - if (!@hasField(T, value)) { - @compileLog(value); - @compileError("renamed field destination does not exist"); - } - } - } - } - // Make an arena for all our allocations if we support it. Otherwise, // use an allocator that always fails. If the arena is already set on // the config, then we reuse that. See memory note in parse docs. @@ -147,7 +132,23 @@ pub fn parse( break :value null; }; - parseIntoField(T, arena_alloc, dst, key, value) catch |err| { + parseIntoField(T, arena_alloc, dst, key, value) catch |err| err: { + // If we get an error parsing a field, then we try to fall + // back to compatibility handlers if able. + if (@hasDecl(T, "compatibility")) { + // If we have a compatibility handler for this key, then + // we call it and see if it handles the error. + if (T.compatibility.get(key)) |handler| { + if (handler(dst, arena_alloc, key, value)) { + log.info( + "compatibility handler for {s} handled error, you may be using a deprecated field: {}", + .{ key, err }, + ); + break :err; + } + } + } + if (comptime !canTrackDiags(T)) return err; // The error set is dependent on comptime T, so we always add @@ -177,6 +178,58 @@ pub fn parse( } } +/// The function type for a compatibility handler. The compatibility +/// handler is documented in the `parse` function documentation. +/// +/// The function type should return bool if the compatibility was +/// handled, and false otherwise. If false is returned then the +/// naturally occurring error will continue to be processed as if +/// this compatibility handler was not present. +/// +/// Compatibility handlers aren't allowed to return errors because +/// they're generally only called in error cases, so we already have +/// an error message to show users. If there is an error in handling +/// the compatibility, then the handler should return false. +pub fn CompatibilityHandler(comptime T: type) type { + return *const fn ( + dst: *T, + alloc: Allocator, + key: []const u8, + value: ?[]const u8, + ) bool; +} + +/// Convenience function to create a compatibility handler that +/// renames a field from `from` to `to`. +pub fn compatibilityRenamed( + comptime T: type, + comptime to: []const u8, +) CompatibilityHandler(T) { + comptime assert(@hasField(T, to)); + + return (struct { + fn compat( + dst: *T, + alloc: Allocator, + key: []const u8, + value: ?[]const u8, + ) bool { + _ = key; + + parseIntoField(T, alloc, dst, to, value) catch |err| { + log.warn("error parsing renamed field {s}: {}", .{ + to, + err, + }); + + return false; + }; + + return true; + } + }).compat; +} + fn formatValueRequired( comptime T: type, arena_alloc: std.mem.Allocator, @@ -401,16 +454,6 @@ pub fn parseIntoField( } } - // Unknown field, is the field renamed? - if (@hasDecl(T, "renamed")) { - for (T.renamed.keys(), T.renamed.values()) |old, new| { - if (mem.eql(u8, old, key)) { - try parseIntoField(T, alloc, dst, new, value); - return; - } - } - } - return error.InvalidField; } @@ -752,6 +795,77 @@ test "parse: diagnostic location" { } } +test "parse: compatibility handler" { + const testing = std.testing; + + var data: struct { + a: bool = false, + _arena: ?ArenaAllocator = null, + + pub const compatibility: std.StaticStringMap( + CompatibilityHandler(@This()), + ) = .initComptime(&.{ + .{ "a", compat }, + }); + + fn compat( + self: *@This(), + alloc: Allocator, + key: []const u8, + value: ?[]const u8, + ) bool { + _ = alloc; + if (std.mem.eql(u8, key, "a")) { + if (value) |v| { + if (mem.eql(u8, v, "yuh")) { + self.a = true; + return true; + } + } + } + + return false; + } + } = .{}; + defer if (data._arena) |arena| arena.deinit(); + + var iter = try std.process.ArgIteratorGeneral(.{}).init( + testing.allocator, + "--a=yuh", + ); + defer iter.deinit(); + try parse(@TypeOf(data), testing.allocator, &data, &iter); + try testing.expect(data._arena != null); + try testing.expect(data.a); +} + +test "parse: compatibility renamed" { + const testing = std.testing; + + var data: struct { + a: bool = false, + b: bool = false, + _arena: ?ArenaAllocator = null, + + pub const compatibility: std.StaticStringMap( + CompatibilityHandler(@This()), + ) = .initComptime(&.{ + .{ "old", compatibilityRenamed(@This(), "a") }, + }); + } = .{}; + defer if (data._arena) |arena| arena.deinit(); + + var iter = try std.process.ArgIteratorGeneral(.{}).init( + testing.allocator, + "--old=true --b=true", + ); + defer iter.deinit(); + try parse(@TypeOf(data), testing.allocator, &data, &iter); + try testing.expect(data._arena != null); + try testing.expect(data.a); + try testing.expect(data.b); +} + test "parseIntoField: ignore underscore-prefixed fields" { const testing = std.testing; var arena = ArenaAllocator.init(testing.allocator); @@ -1176,24 +1290,6 @@ test "parseIntoField: tagged union missing tag" { ); } -test "parseIntoField: renamed field" { - const testing = std.testing; - var arena = ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const alloc = arena.allocator(); - - var data: struct { - a: []const u8, - - const renamed = std.StaticStringMap([]const u8).initComptime(&.{ - .{ "old", "a" }, - }); - } = undefined; - - try parseIntoField(@TypeOf(data), alloc, &data, "old", "42"); - try testing.expectEqualStrings("42", data.a); -} - /// An iterator that considers its location to be CLI args. It /// iterates through an underlying iterator and increments a counter /// to track the current CLI arg index. diff --git a/src/cli/boo.zig b/src/cli/boo.zig index 7ecbf79fb..47c8ab741 100644 --- a/src/cli/boo.zig +++ b/src/cli/boo.zig @@ -176,7 +176,7 @@ const Boo = struct { pub fn run(gpa: Allocator) !u8 { // Disable on non-desktop systems. switch (builtin.os.tag) { - .windows, .macos, .linux => {}, + .windows, .macos, .linux, .freebsd => {}, else => return 1, } diff --git a/src/cli/edit_config.zig b/src/cli/edit_config.zig new file mode 100644 index 000000000..3be88e090 --- /dev/null +++ b/src/cli/edit_config.zig @@ -0,0 +1,159 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const assert = std.debug.assert; +const args = @import("args.zig"); +const Allocator = std.mem.Allocator; +const Action = @import("action.zig").Action; +const configpkg = @import("../config.zig"); +const internal_os = @import("../os/main.zig"); +const Config = configpkg.Config; + +pub const Options = struct { + pub fn deinit(self: Options) void { + _ = self; + } + + /// Enables `-h` and `--help` to work. + pub fn help(self: Options) !void { + _ = self; + return Action.help_error; + } +}; + +/// The `edit-config` command opens the Ghostty configuration file in the +/// editor specified by the `$VISUAL` or `$EDITOR` environment variables. +/// +/// IMPORTANT: This command will not reload the configuration after +/// editing. You will need to manually reload the configuration using the +/// application menu, configured keybind, or by restarting Ghostty. We +/// plan to auto-reload in the future, but Ghostty isn't capable of +/// this yet. +/// +/// The filepath opened is the default user-specific configuration +/// file, which is typically located at `$XDG_CONFIG_HOME/ghostty/config`. +/// On macOS, this may also be located at +/// `~/Library/Application Support/com.mitchellh.ghostty/config`. +/// On macOS, whichever path exists and is non-empty will be prioritized, +/// prioritizing the Application Support directory if neither are +/// non-empty. +/// +/// This command prefers the `$VISUAL` environment variable over `$EDITOR`, +/// if both are set. If neither are set, it will print an error +/// and exit. +pub fn run(alloc: Allocator) !u8 { + // Implementation note (by @mitchellh): I do proper memory cleanup + // throughout this command, even though we plan on doing `exec`. + // I do this out of good hygiene in case we ever change this to + // not using `exec` anymore and because this command isn't performance + // critical where setting up the defer cleanup is a problem. + + const stderr = std.io.getStdErr().writer(); + + var opts: Options = .{}; + defer opts.deinit(); + + { + var iter = try args.argsIterator(alloc); + defer iter.deinit(); + try args.parse(Options, alloc, &opts, &iter); + } + + // We load the configuration once because that will write our + // default configuration files to disk. We don't use the config. + var config = try Config.load(alloc); + defer config.deinit(); + + // Find the preferred path. + const path = try Config.preferredDefaultFilePath(alloc); + defer alloc.free(path); + + // We don't currently support Windows because we use the exec syscall. + if (comptime builtin.os.tag == .windows) { + try stderr.print( + \\The `ghostty +edit-config` command is not supported on Windows. + \\Please edit the configuration file manually at the following path: + \\ + \\{s} + \\ + , + .{path}, + ); + return 1; + } + + // Get our editor + const get_env_: ?internal_os.GetEnvResult = env: { + // VISUAL vs. EDITOR: https://unix.stackexchange.com/questions/4859/visual-vs-editor-what-s-the-difference + if (try internal_os.getenv(alloc, "VISUAL")) |v| { + if (v.value.len > 0) break :env v; + v.deinit(alloc); + } + + if (try internal_os.getenv(alloc, "EDITOR")) |v| { + if (v.value.len > 0) break :env v; + v.deinit(alloc); + } + + break :env null; + }; + defer if (get_env_) |v| v.deinit(alloc); + const editor: []const u8 = if (get_env_) |v| v.value else ""; + + // If we don't have `$EDITOR` set then we can't do anything + // but we can still print a helpful message. + if (editor.len == 0) { + try stderr.print( + \\The $EDITOR or $VISUAL environment variable is not set or is empty. + \\This environment variable is required to edit the Ghostty configuration + \\via this CLI command. + \\ + \\Please set the environment variable to your preferred terminal + \\text editor and try again. + \\ + \\If you prefer to edit the configuration file another way, + \\you can find the configuration file at the following path: + \\ + \\ + , + .{}, + ); + + // Output the path using the OSC8 sequence so that it is linked. + try stderr.print( + "\x1b]8;;file://{s}\x1b\\{s}\x1b]8;;\x1b\\\n", + .{ path, path }, + ); + + return 1; + } + + // We require libc because we want to use std.c.environ for envp + // and not have to build that ourselves. We can remove this + // limitation later but Ghostty already heavily requires libc + // so this is not a big deal. + comptime assert(builtin.link_libc); + + const editorZ = try alloc.dupeZ(u8, editor); + defer alloc.free(editorZ); + const pathZ = try alloc.dupeZ(u8, path); + defer alloc.free(pathZ); + const err = std.posix.execvpeZ( + editorZ, + &.{ editorZ, pathZ }, + std.c.environ, + ); + + // If we reached this point then exec failed. + try stderr.print( + \\Failed to execute the editor. Error code={}. + \\ + \\This is usually due to the executable path not existing, invalid + \\permissions, or the shell environment not being set up + \\correctly. + \\ + \\Editor: {s} + \\Path: {s} + \\ + , .{ err, editor, path }); + return 1; +} diff --git a/src/cli/list_themes.zig b/src/cli/list_themes.zig index 4bb8a74eb..e80a92286 100644 --- a/src/cli/list_themes.zig +++ b/src/cli/list_themes.zig @@ -115,7 +115,8 @@ pub fn run(gpa_alloc: std.mem.Allocator) !u8 { const stderr = std.io.getStdErr().writer(); const stdout = std.io.getStdOut().writer(); - if (global_state.resources_dir == null) + const resources_dir = global_state.resources_dir.app(); + if (resources_dir == null) try stderr.print("Could not find the Ghostty resources directory. Please ensure " ++ "that Ghostty is installed correctly.\n", .{}); diff --git a/src/config.zig b/src/config.zig index 018d0e6e8..ac38eb89c 100644 --- a/src/config.zig +++ b/src/config.zig @@ -20,6 +20,7 @@ pub const ConfirmCloseSurface = Config.ConfirmCloseSurface; pub const CopyOnSelect = Config.CopyOnSelect; pub const CustomShaderAnimation = Config.CustomShaderAnimation; pub const FontSyntheticStyle = Config.FontSyntheticStyle; +pub const FontShapingBreak = Config.FontShapingBreak; pub const FontStyle = Config.FontStyle; pub const FreetypeLoadFlags = Config.FreetypeLoadFlags; pub const Keybinds = Config.Keybinds; @@ -31,8 +32,11 @@ pub const RepeatableFontVariation = Config.RepeatableFontVariation; pub const RepeatableString = Config.RepeatableString; pub const RepeatableStringMap = @import("config/RepeatableStringMap.zig"); pub const RepeatablePath = Config.RepeatablePath; +pub const Path = Config.Path; pub const ShellIntegrationFeatures = Config.ShellIntegrationFeatures; pub const WindowPaddingColor = Config.WindowPaddingColor; +pub const BackgroundImagePosition = Config.BackgroundImagePosition; +pub const BackgroundImageFit = Config.BackgroundImageFit; // Alternate APIs pub const CAPI = @import("config/CAPI.zig"); diff --git a/src/config/Config.zig b/src/config/Config.zig index 6bc3a7f23..ef8f48ee9 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -46,14 +46,22 @@ const c = @cImport({ @cInclude("unistd.h"); }); -/// Renamed fields, used by cli.parse -pub const renamed = std.StaticStringMap([]const u8).initComptime(&.{ +pub const compatibility = std.StaticStringMap( + cli.CompatibilityHandler(Config), +).initComptime(&.{ // Ghostty 1.1 introduced background-blur support for Linux which // doesn't support a specific radius value. The renaming is to let // one field be used for both platforms (macOS retained the ability // to set a radius). - .{ "background-blur-radius", "background-blur" }, - .{ "adw-toolbar-style", "gtk-toolbar-style" }, + .{ "background-blur-radius", cli.compatibilityRenamed(Config, "background-blur") }, + + // Ghostty 1.2 renamed all our adw options to gtk because we now have + // a hard dependency on libadwaita. + .{ "adw-toolbar-style", cli.compatibilityRenamed(Config, "gtk-toolbar-style") }, + + // Ghostty 1.2 removed the `hidden` value from `gtk-tabs-location` and + // moved it to `window-show-tab-bar`. + .{ "gtk-tabs-location", compatGtkTabsLocation }, }); /// The font families to use. @@ -262,6 +270,32 @@ pub const renamed = std.StaticStringMap([]const u8).initComptime(&.{ /// This is currently only supported on macOS. @"font-thicken-strength": u8 = 255, +/// Locations to break font shaping into multiple runs. +/// +/// A "run" is a contiguous segment of text that is shaped together. "Shaping" +/// is the process of converting text (codepoints) into glyphs (renderable +/// characters). This is how ligatures are formed, among other things. +/// For example, if a coding font turns "!=" into a single glyph, then it +/// must see "!" and "=" next to each other in a single run. When a run +/// is broken, the text is shaped separately. To continue our example, if +/// "!" is at the end of one run and "=" is at the start of the next run, +/// then the ligature will not be formed. +/// +/// Ghostty breaks runs at certain points to improve readability or usability. +/// For example, Ghostty by default will break runs under the cursor so that +/// text editing can see the individual characters rather than a ligature. +/// This configuration lets you configure this behavior. +/// +/// Combine values with a comma to set multiple options. Prefix an +/// option with "no-" to disable it. Enabling and disabling options +/// can be done at the same time. +/// +/// Available options: +/// +/// * `cursor` - Break runs under the cursor. +/// +@"font-shaping-break": FontShapingBreak = .{}, + /// What color space to use when performing alpha blending. /// /// This affects the appearance of text and of any images with transparency. @@ -466,6 +500,93 @@ background: Color = .{ .r = 0x28, .g = 0x2C, .b = 0x34 }, /// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color. foreground: Color = .{ .r = 0xFF, .g = 0xFF, .b = 0xFF }, +/// Background image for the terminal. +/// +/// This should be a path to a PNG or JPEG file, other image formats are +/// not yet supported. +/// +/// The background image is currently per-terminal, not per-window. If +/// you are a heavy split user, the background image will be repeated across +/// splits. A future improvement to Ghostty will address this. +/// +/// WARNING: Background images are currently duplicated in VRAM per-terminal. +/// For sufficiently large images, this could lead to a large increase in +/// memory usage (specifically VRAM usage). A future Ghostty improvement +/// will resolve this by sharing image textures across terminals. +@"background-image": ?Path = null, + +/// Background image opacity. +/// +/// This is relative to the value of `background-opacity`. +/// +/// A value of `1.0` (the default) will result in the background image being +/// placed on top of the general background color, and then the combined result +/// will be adjusted to the opacity specified by `background-opacity`. +/// +/// A value less than `1.0` will result in the background image being mixed +/// with the general background color before the combined result is adjusted +/// to the configured `background-opacity`. +/// +/// A value greater than `1.0` will result in the background image having a +/// higher opacity than the general background color. For instance, if the +/// configured `background-opacity` is `0.5` and `background-image-opacity` +/// is set to `1.5`, then the final opacity of the background image will be +/// `0.5 * 1.5 = 0.75`. +@"background-image-opacity": f32 = 1.0, + +/// Background image position. +/// +/// Valid values are: +/// * `top-left` +/// * `top-center` +/// * `top-right` +/// * `center-left` +/// * `center` +/// * `center-right` +/// * `bottom-left` +/// * `bottom-center` +/// * `bottom-right` +/// +/// The default value is `center`. +@"background-image-position": BackgroundImagePosition = .center, + +/// Background image fit. +/// +/// Valid values are: +/// +/// * `contain` +/// +/// Preserving the aspect ratio, scale the background image to the largest +/// size that can still be contained within the terminal, so that the whole +/// image is visible. +/// +/// * `cover` +/// +/// Preserving the aspect ratio, scale the background image to the smallest +/// size that can completely cover the terminal. This may result in one or +/// more edges of the image being clipped by the edge of the terminal. +/// +/// * `stretch` +/// +/// Stretch the background image to the full size of the terminal, without +/// preserving the aspect ratio. +/// +/// * `none` +/// +/// Don't scale the background image. +/// +/// The default value is `contain`. +@"background-image-fit": BackgroundImageFit = .contain, + +/// Whether to repeat the background image or not. +/// +/// If this is set to true, the background image will be repeated if there +/// would otherwise be blank space around it because it doesn't completely +/// fill the terminal area. +/// +/// The default value is `false`. +@"background-image-repeat": bool = false, + /// The foreground and background color for selection. If this is not set, then /// the selection color is just the inverted window background and foreground /// (note: not to be confused with the cell bg/fg). @@ -942,12 +1063,17 @@ title: ?[:0]const u8 = null, /// The setting that will change the application class value. /// /// This controls the class field of the `WM_CLASS` X11 property (when running -/// under X11), and the Wayland application ID (when running under Wayland). +/// under X11), the Wayland application ID (when running under Wayland), and the +/// bus name that Ghostty uses to connect to DBus. /// /// Note that changing this value between invocations will create new, separate /// instances, of Ghostty when running with `gtk-single-instance=true`. See that /// option for more details. /// +/// Changing this value may break launching Ghostty from `.desktop` files, via +/// DBus activation, or systemd user services as the system is expecting Ghostty +/// to connect to DBus using the default `class` when it is launched. +/// /// The class name must follow the requirements defined [in the GTK /// documentation](https://docs.gtk.org/gio/type_func.Application.id_is_valid.html). /// @@ -1494,6 +1620,27 @@ keybind: Keybinds = .{}, /// * `end` - Insert the new tab at the end of the tab list. @"window-new-tab-position": WindowNewTabPosition = .current, +/// Whether to show the tab bar. +/// +/// Valid values: +/// +/// - `always` +/// +/// Always display the tab bar, even when there's only one tab. +/// +/// - `auto` *(default)* +/// +/// Automatically show and hide the tab bar. The tab bar is only +/// shown when there are two or more tabs present. +/// +/// - `never` +/// +/// Never show the tab bar. Tabs are only accessible via the tab +/// overview or by keybind actions. +/// +/// Currently only supported on Linux (GTK). +@"window-show-tab-bar": WindowShowTabBar = .auto, + /// Background color for the window titlebar. This only takes effect if /// window-theme is set to ghostty. Currently only supported in the GTK app /// runtime. @@ -1975,6 +2122,28 @@ keybind: Keybinds = .{}, /// Example: `cursor`, `no-cursor`, `sudo`, `no-sudo`, `title`, `no-title` @"shell-integration-features": ShellIntegrationFeatures = .{}, +/// Custom entries into the command palette. +/// +/// Each entry requires the title, the corresponding action, and an optional +/// description. Each field should be prefixed with the field name, a colon +/// (`:`), and then the specified value. The syntax for actions is identical +/// to the one for keybind actions. Whitespace in between fields is ignored. +/// +/// ```ini +/// command-palette-entry = title:Reset Font Style, action:csi:0m +/// command-palette-entry = title:Crash on Main Thread,description:Causes a crash on the main (UI) thread.,action:crash:main +/// ``` +/// +/// By default, the command palette is preloaded with most actions that might +/// be useful in an interactive setting yet do not have easily accessible or +/// memorizable shortcuts. The default entries can be cleared by setting this +/// setting to an empty value: +/// +/// ```ini +/// command-palette-entry = +/// ``` +@"command-palette-entry": RepeatableCommand = .{}, + /// Sets the reporting format for OSC sequences that request color information. /// Ghostty currently supports OSC 10 (foreground), OSC 11 (background), and /// OSC 4 (256 color palette) queries, and by default the reported values @@ -2009,9 +2178,59 @@ keybind: Keybinds = .{}, /// causing the window to be completely black. If this happens, you can /// unset this configuration to disable the shader. /// -/// The shader API is identical to the Shadertoy API: you specify a `mainImage` -/// function and the available uniforms match Shadertoy. The iChannel0 uniform -/// is a texture containing the rendered terminal screen. +/// Custom shader support is based on and compatible with the Shadertoy shaders. +/// Shaders should specify a `mainImage` function and the available uniforms +/// largely match Shadertoy, with some caveats and Ghostty-specific extensions. +/// +/// The uniform values available to shaders are as follows: +/// +/// * `sampler2D iChannel0` - Input texture. +/// +/// A texture containing the current terminal screen. If multiple custom +/// shaders are specified, the output of previous shaders is written to +/// this texture, to allow combining multiple effects. +/// +/// * `vec3 iResolution` - Output texture size, `[width, height, 1]` (in px). +/// +/// * `float iTime` - Time in seconds since first frame was rendered. +/// +/// * `float iTimeDelta` - Time in seconds since previous frame was rendered. +/// +/// * `float iFrameRate` - Average framerate. (NOT CURRENTLY SUPPORTED) +/// +/// * `int iFrame` - Number of frames that have been rendered so far. +/// +/// * `float iChannelTime[4]` - Current time for video or sound input. (N/A) +/// +/// * `vec3 iChannelResolution[4]` - Resolutions of the 4 input samplers. +/// +/// Currently only `iChannel0` exists, and `iChannelResolution[0]` is +/// identical to `iResolution`. +/// +/// * `vec4 iMouse` - Mouse input info. (NOT CURRENTLY SUPPORTED) +/// +/// * `vec4 iDate` - Date/time info. (NOT CURRENTLY SUPPORTED) +/// +/// * `float iSampleRate` - Sample rate for audio. (N/A) +/// +/// Ghostty-specific extensions: +/// +/// * `vec4 iCurrentCursor` - Info about the terminal cursor. +/// +/// - `iCurrentCursor.xy` is the -X, +Y corner of the current cursor. +/// - `iCurrentCursor.zw` is the width and height of the current cursor. +/// +/// * `vec4 iPreviousCursor` - Info about the previous terminal cursor. +/// +/// * `vec4 iCurrentCursorColor` - Color of the terminal cursor. +/// +/// * `vec4 iPreviousCursorColor` - Color of the previous terminal cursor. +/// +/// * `float iTimeCursorChange` - Timestamp of terminal cursor change. +/// +/// When the terminal cursor changes position or color, this is set to +/// the same time as the `iTime` uniform, allowing you to compute the +/// time since the change by subtracting this from `iTime`. /// /// If the shader fails to compile, the shader will be ignored. Any errors /// related to shader compilation will not show up as configuration errors @@ -2734,6 +2953,9 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config { // Add our default keybindings try result.keybind.init(alloc); + // Add our default command palette entries + try result.@"command-palette-entry".init(alloc); + // Add our default link for URL detection try result.link.links.append(alloc, .{ .regex = url.regex, @@ -2759,24 +2981,20 @@ pub fn loadIter( /// `path` must be resolved and absolute. pub fn loadFile(self: *Config, alloc: Allocator, path: []const u8) !void { assert(std.fs.path.isAbsolute(path)); - - var file = try std.fs.openFileAbsolute(path, .{}); - defer file.close(); - - const stat = try file.stat(); - switch (stat.kind) { - .file => {}, - else => |kind| { - log.warn("config-file {s}: not reading because file type is {s}", .{ - path, - @tagName(kind), - }); + var file = openFile(path) catch |err| switch (err) { + error.NotAFile => { + log.warn( + "config-file {s}: not reading because it is not a file", + .{path}, + ); return; }, - } + + else => return err, + }; + defer file.close(); std.log.info("reading configuration file path={s}", .{path}); - var buf_reader = std.io.bufferedReader(file.reader()); const reader = buf_reader.reader(); const Iter = cli.args.LineIterator(@TypeOf(reader)); @@ -2831,13 +3049,13 @@ fn writeConfigTemplate(path: []const u8) !void { /// is also loaded. pub fn loadDefaultFiles(self: *Config, alloc: Allocator) !void { // Load XDG first - const xdg_path = try internal_os.xdg.config(alloc, .{ .subdir = "ghostty/config" }); + const xdg_path = try defaultXdgPath(alloc); defer alloc.free(xdg_path); const xdg_action = self.loadOptionalFile(alloc, xdg_path); // On macOS load the app support directory as well if (comptime builtin.os.tag == .macos) { - const app_support_path = try internal_os.macos.appSupportDir(alloc, "config"); + const app_support_path = try defaultAppSupportPath(alloc); defer alloc.free(app_support_path); const app_support_action = self.loadOptionalFile(alloc, app_support_path); @@ -2857,6 +3075,102 @@ pub fn loadDefaultFiles(self: *Config, alloc: Allocator) !void { } } +/// Default path for the XDG home configuration file. Returned value +/// must be freed by the caller. +fn defaultXdgPath(alloc: Allocator) ![]const u8 { + return try internal_os.xdg.config( + alloc, + .{ .subdir = "ghostty/config" }, + ); +} + +/// Default path for the macOS Application Support configuration file. +/// Returned value must be freed by the caller. +fn defaultAppSupportPath(alloc: Allocator) ![]const u8 { + return try internal_os.macos.appSupportDir(alloc, "config"); +} + +/// Returns the path to the preferred default configuration file. +/// This is the file where users should place their configuration. +/// +/// This doesn't create or populate the file with any default +/// contents; downstream callers must handle this. +/// +/// The returned value must be freed by the caller. +pub fn preferredDefaultFilePath(alloc: Allocator) ![]const u8 { + switch (builtin.os.tag) { + .macos => { + // macOS prefers the Application Support directory + // if it exists. + const app_support_path = try defaultAppSupportPath(alloc); + if (openFile(app_support_path)) |f| { + f.close(); + return app_support_path; + } else |_| {} + + // Try the XDG path if it exists + const xdg_path = try defaultXdgPath(alloc); + if (openFile(xdg_path)) |f| { + f.close(); + alloc.free(app_support_path); + return xdg_path; + } else |_| {} + defer alloc.free(xdg_path); + + // Neither exist, use app support + return app_support_path; + }, + + // All other platforms use XDG only + else => return try defaultXdgPath(alloc), + } +} + +const OpenFileError = error{ + FileNotFound, + FileIsEmpty, + FileOpenFailed, + NotAFile, +}; + +/// Opens the file at the given path and returns the file handle +/// if it exists and is non-empty. This also constrains the possible +/// errors to a smaller set that we can explicitly handle. +fn openFile(path: []const u8) OpenFileError!std.fs.File { + assert(std.fs.path.isAbsolute(path)); + + var file = std.fs.openFileAbsolute( + path, + .{}, + ) catch |err| switch (err) { + error.FileNotFound => return OpenFileError.FileNotFound, + else => { + log.warn("unexpected file open error path={s} err={}", .{ + path, + err, + }); + return OpenFileError.FileOpenFailed; + }, + }; + errdefer file.close(); + + const stat = file.stat() catch |err| { + log.warn("error getting file stat path={s} err={}", .{ + path, + err, + }); + return OpenFileError.FileOpenFailed; + }; + switch (stat.kind) { + .file => {}, + else => return OpenFileError.NotAFile, + } + + if (stat.size == 0) return OpenFileError.FileIsEmpty; + + return file; +} + /// Load and parse the CLI args. pub fn loadCliArgs(self: *Config, alloc_gpa: Allocator) !void { switch (builtin.os.tag) { @@ -3156,6 +3470,15 @@ fn expandPaths(self: *Config, base: []const u8) !void { &self._diagnostics, ); }, + ?RepeatablePath, ?Path => { + if (@field(self, field.name)) |*path| { + try path.expand( + arena_alloc, + base, + &self._diagnostics, + ); + } + }, else => {}, } } @@ -3503,6 +3826,27 @@ pub fn parseManuallyHook( return true; } +/// parseFieldManuallyFallback is a fallback called only when +/// parsing the field directly failed. It can be used to implement +/// backward compatibility. Since this is only called when parsing +/// fails, it doesn't impact happy-path performance. +fn compatGtkTabsLocation( + self: *Config, + alloc: Allocator, + key: []const u8, + value: ?[]const u8, +) bool { + _ = alloc; + assert(std.mem.eql(u8, key, "gtk-tabs-location")); + + if (std.mem.eql(u8, value orelse "", "hidden")) { + self.@"window-show-tab-bar" = .never; + return true; + } + + return false; +} + /// Create a shallow copy of this config. This will share all the memory /// allocated with the previous config but will have a new arena for /// any changes or new allocations. The config should have `deinit` @@ -4714,6 +5058,12 @@ pub const Keybinds = struct { .{ .reset_font_size = {} }, ); + try self.set.put( + alloc, + .{ .key = .{ .unicode = 'j' }, .mods = .{ .shift = true, .ctrl = true, .super = true } }, + .{ .write_screen_file = .copy }, + ); + try self.set.put( alloc, .{ .key = .{ .unicode = 'j' }, .mods = inputpkg.ctrlOrSuper(.{ .shift = true }) }, @@ -4820,25 +5170,29 @@ pub const Keybinds = struct { .{ .key = .{ .unicode = 'w' }, .mods = .{ .ctrl = true, .shift = true } }, .{ .close_tab = {} }, ); - try self.set.put( + try self.set.putFlags( alloc, .{ .key = .{ .physical = .arrow_left }, .mods = .{ .ctrl = true, .shift = true } }, .{ .previous_tab = {} }, + .{ .performable = true }, ); - try self.set.put( + try self.set.putFlags( alloc, .{ .key = .{ .physical = .arrow_right }, .mods = .{ .ctrl = true, .shift = true } }, .{ .next_tab = {} }, + .{ .performable = true }, ); - try self.set.put( + try self.set.putFlags( alloc, .{ .key = .{ .physical = .page_up }, .mods = .{ .ctrl = true } }, .{ .previous_tab = {} }, + .{ .performable = true }, ); - try self.set.put( + try self.set.putFlags( alloc, .{ .key = .{ .physical = .page_down }, .mods = .{ .ctrl = true } }, .{ .next_tab = {} }, + .{ .performable = true }, ); try self.set.put( alloc, @@ -4850,57 +5204,67 @@ pub const Keybinds = struct { .{ .key = .{ .unicode = 'e' }, .mods = .{ .ctrl = true, .shift = true } }, .{ .new_split = .down }, ); - try self.set.put( + try self.set.putFlags( alloc, .{ .key = .{ .physical = .bracket_left }, .mods = .{ .ctrl = true, .super = true } }, .{ .goto_split = .previous }, + .{ .performable = true }, ); - try self.set.put( + try self.set.putFlags( alloc, .{ .key = .{ .physical = .bracket_right }, .mods = .{ .ctrl = true, .super = true } }, .{ .goto_split = .next }, + .{ .performable = true }, ); - try self.set.put( + try self.set.putFlags( alloc, .{ .key = .{ .physical = .arrow_up }, .mods = .{ .ctrl = true, .alt = true } }, .{ .goto_split = .up }, + .{ .performable = true }, ); - try self.set.put( + try self.set.putFlags( alloc, .{ .key = .{ .physical = .arrow_down }, .mods = .{ .ctrl = true, .alt = true } }, .{ .goto_split = .down }, + .{ .performable = true }, ); - try self.set.put( + try self.set.putFlags( alloc, .{ .key = .{ .physical = .arrow_left }, .mods = .{ .ctrl = true, .alt = true } }, .{ .goto_split = .left }, + .{ .performable = true }, ); - try self.set.put( + try self.set.putFlags( alloc, .{ .key = .{ .physical = .arrow_right }, .mods = .{ .ctrl = true, .alt = true } }, .{ .goto_split = .right }, + .{ .performable = true }, ); // Resizing splits - try self.set.put( + try self.set.putFlags( alloc, .{ .key = .{ .physical = .arrow_up }, .mods = .{ .super = true, .ctrl = true, .shift = true } }, .{ .resize_split = .{ .up, 10 } }, + .{ .performable = true }, ); - try self.set.put( + try self.set.putFlags( alloc, .{ .key = .{ .physical = .arrow_down }, .mods = .{ .super = true, .ctrl = true, .shift = true } }, .{ .resize_split = .{ .down, 10 } }, + .{ .performable = true }, ); - try self.set.put( + try self.set.putFlags( alloc, .{ .key = .{ .physical = .arrow_left }, .mods = .{ .super = true, .ctrl = true, .shift = true } }, .{ .resize_split = .{ .left, 10 } }, + .{ .performable = true }, ); - try self.set.put( + try self.set.putFlags( alloc, .{ .key = .{ .physical = .arrow_right }, .mods = .{ .super = true, .ctrl = true, .shift = true } }, .{ .resize_split = .{ .right, 10 } }, + .{ .performable = true }, ); // Viewport scrolling @@ -4971,22 +5335,24 @@ pub const Keybinds = struct { const end: u21 = '8'; var i: u21 = start; while (i <= end) : (i += 1) { - try self.set.put( + try self.set.putFlags( alloc, .{ .key = .{ .unicode = i }, .mods = mods, }, .{ .goto_tab = (i - start) + 1 }, + .{ .performable = true }, ); } - try self.set.put( + try self.set.putFlags( alloc, .{ .key = .{ .unicode = '9' }, .mods = mods, }, .{ .last_tab = {} }, + .{ .performable = true }, ); } @@ -5874,6 +6240,11 @@ pub const FontSyntheticStyle = packed struct { @"bold-italic": bool = true, }; +/// See "font-shaping-break" for documentation +pub const FontShapingBreak = packed struct { + cursor: bool = true, +}; + /// See "link" for documentation. pub const RepeatableLink = struct { const Self = @This(); @@ -5956,6 +6327,150 @@ pub const ShellIntegrationFeatures = packed struct { title: bool = true, }; +pub const RepeatableCommand = struct { + value: std.ArrayListUnmanaged(inputpkg.Command) = .empty, + + pub fn init(self: *RepeatableCommand, alloc: Allocator) !void { + self.value = .empty; + try self.value.appendSlice(alloc, inputpkg.command.defaults); + } + + pub fn parseCLI( + self: *RepeatableCommand, + alloc: Allocator, + input_: ?[]const u8, + ) !void { + // Unset or empty input clears the list + const input = input_ orelse ""; + if (input.len == 0) { + self.value.clearRetainingCapacity(); + return; + } + + const cmd = try cli.args.parseAutoStruct( + inputpkg.Command, + alloc, + input, + ); + try self.value.append(alloc, cmd); + } + + /// Deep copy of the struct. Required by Config. + pub fn clone(self: *const RepeatableCommand, alloc: Allocator) Allocator.Error!RepeatableCommand { + const value = try self.value.clone(alloc); + for (value.items) |*item| { + item.* = try item.clone(alloc); + } + + return .{ .value = value }; + } + + /// Compare if two of our value are equal. Required by Config. + pub fn equal(self: RepeatableCommand, other: RepeatableCommand) bool { + if (self.value.items.len != other.value.items.len) return false; + for (self.value.items, other.value.items) |a, b| { + if (!a.equal(b)) return false; + } + + return true; + } + + /// Used by Formatter + pub fn formatEntry(self: RepeatableCommand, formatter: anytype) !void { + if (self.value.items.len == 0) { + try formatter.formatEntry(void, {}); + return; + } + + var buf: [4096]u8 = undefined; + for (self.value.items) |item| { + const str = if (item.description.len > 0) std.fmt.bufPrint( + &buf, + "title:{s},description:{s},action:{}", + .{ item.title, item.description, item.action }, + ) else std.fmt.bufPrint( + &buf, + "title:{s},action:{}", + .{ item.title, item.action }, + ); + try formatter.formatEntry([]const u8, str catch return error.OutOfMemory); + } + } + + test "RepeatableCommand parseCLI" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var list: RepeatableCommand = .{}; + try list.parseCLI(alloc, "title:Foo,action:ignore"); + try list.parseCLI(alloc, "title:Bar,description:bobr,action:text:ale bydle"); + try list.parseCLI(alloc, "title:Quux,description:boo,action:increase_font_size:2.5"); + + try testing.expectEqual(@as(usize, 3), list.value.items.len); + + try testing.expectEqual(inputpkg.Binding.Action.ignore, list.value.items[0].action); + try testing.expectEqualStrings("Foo", list.value.items[0].title); + + try testing.expect(list.value.items[1].action == .text); + try testing.expectEqualStrings("ale bydle", list.value.items[1].action.text); + try testing.expectEqualStrings("Bar", list.value.items[1].title); + try testing.expectEqualStrings("bobr", list.value.items[1].description); + + try testing.expectEqual( + inputpkg.Binding.Action{ .increase_font_size = 2.5 }, + list.value.items[2].action, + ); + try testing.expectEqualStrings("Quux", list.value.items[2].title); + try testing.expectEqualStrings("boo", list.value.items[2].description); + + try list.parseCLI(alloc, ""); + try testing.expectEqual(@as(usize, 0), list.value.items.len); + } + + test "RepeatableCommand formatConfig empty" { + const testing = std.testing; + var buf = std.ArrayList(u8).init(testing.allocator); + defer buf.deinit(); + + var list: RepeatableCommand = .{}; + try list.formatEntry(formatterpkg.entryFormatter("a", buf.writer())); + try std.testing.expectEqualSlices(u8, "a = \n", buf.items); + } + + test "RepeatableCommand formatConfig single item" { + 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:Bobr, action:text:Bober"); + try list.formatEntry(formatterpkg.entryFormatter("a", buf.writer())); + try std.testing.expectEqualSlices(u8, "a = title:Bobr,action:text:Bober\n", buf.items); + } + + test "RepeatableCommand formatConfig multiple items" { + 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:Bobr, action:text:kurwa"); + try list.parseCLI(alloc, "title:Ja, description: pierdole, action:text:jakie bydle"); + try list.formatEntry(formatterpkg.entryFormatter("a", buf.writer())); + try std.testing.expectEqualSlices(u8, "a = title:Bobr,action:text:kurwa\na = title:Ja,description:pierdole,action:text:jakie bydle\n", buf.items); + } +}; + /// OSC 4, 10, 11, and 12 default color reporting format. pub const OSCColorReportFormat = enum { none, @@ -6048,7 +6563,6 @@ pub const GtkSingleInstance = enum { pub const GtkTabsLocation = enum { top, bottom, - hidden, }; /// See gtk-toolbar-style @@ -6099,6 +6613,13 @@ pub const WindowNewTabPosition = enum { end, }; +/// See window-show-tab-bar +pub const WindowShowTabBar = enum { + always, + auto, + never, +}; + /// See resize-overlay pub const ResizeOverlay = enum { always, @@ -6411,6 +6932,28 @@ pub const AlphaBlending = enum { } }; +/// See background-image-position +pub const BackgroundImagePosition = enum { + @"top-left", + @"top-center", + @"top-right", + @"center-left", + @"center-center", + @"center-right", + @"bottom-left", + @"bottom-center", + @"bottom-right", + center, +}; + +/// See background-image-fit +pub const BackgroundImageFit = enum { + contain, + cover, + stretch, + none, +}; + /// See freetype-load-flag pub const FreetypeLoadFlags = packed struct { // The defaults here at the time of writing this match the defaults diff --git a/src/config/edit.zig b/src/config/edit.zig index 871a1a755..ae4394942 100644 --- a/src/config/edit.zig +++ b/src/config/edit.zig @@ -20,10 +20,10 @@ pub fn open(alloc_gpa: Allocator) !void { // Use an arena to make memory management easier in here. var arena = ArenaAllocator.init(alloc_gpa); defer arena.deinit(); - const alloc = arena.allocator(); + const alloc_arena = arena.allocator(); // Get the path we should open - const config_path = try configPath(alloc); + const config_path = try configPath(alloc_arena); // Create config directory recursively. if (std.fs.path.dirname(config_path)) |config_dir| { @@ -41,7 +41,7 @@ pub fn open(alloc_gpa: Allocator) !void { } }; - try internal_os.open(alloc, .text, config_path); + try internal_os.open(alloc_gpa, .text, config_path); } /// Returns the config path to use for open for the current OS. diff --git a/src/config/theme.zig b/src/config/theme.zig index 21d6faf08..8fa7c93dc 100644 --- a/src/config/theme.zig +++ b/src/config/theme.zig @@ -56,7 +56,7 @@ pub const Location = enum { }, .resources => try std.fs.path.join(arena_alloc, &.{ - global_state.resources_dir orelse return null, + global_state.resources_dir.app() orelse return null, "themes", }), }; diff --git a/src/datastruct/array_list_collection.zig b/src/datastruct/array_list_collection.zig new file mode 100644 index 000000000..d3fbddb13 --- /dev/null +++ b/src/datastruct/array_list_collection.zig @@ -0,0 +1,44 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; + +/// A collection of ArrayLists with methods for bulk operations. +pub fn ArrayListCollection(comptime T: type) type { + return struct { + const Self = ArrayListCollection(T); + const ArrayListT = std.ArrayListUnmanaged(T); + + // An array containing the lists that belong to this collection. + lists: []ArrayListT, + + // The collection will be initialized with empty ArrayLists. + pub fn init( + alloc: Allocator, + list_count: usize, + initial_capacity: usize, + ) Allocator.Error!Self { + const self: Self = .{ + .lists = try alloc.alloc(ArrayListT, list_count), + }; + + for (self.lists) |*list| { + list.* = try .initCapacity(alloc, initial_capacity); + } + + return self; + } + + pub fn deinit(self: *Self, alloc: Allocator) void { + for (self.lists) |*list| { + list.deinit(alloc); + } + alloc.free(self.lists); + } + + /// Clear all lists in the collection, retaining capacity. + pub fn reset(self: *Self) void { + for (self.lists) |*list| { + list.clearRetainingCapacity(); + } + } + }; +} diff --git a/src/file_type.zig b/src/file_type.zig new file mode 100644 index 000000000..18dd7a4a5 --- /dev/null +++ b/src/file_type.zig @@ -0,0 +1,102 @@ +const std = @import("std"); + +const type_details: []const struct { + typ: FileType, + sigs: []const []const ?u8, + exts: []const []const u8, +} = &.{ + .{ + .typ = .jpeg, + .sigs = &.{ + &.{ 0xFF, 0xD8, 0xFF, 0xDB }, + &.{ 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01 }, + &.{ 0xFF, 0xD8, 0xFF, 0xEE }, + &.{ 0xFF, 0xD8, 0xFF, 0xE1, null, null, 0x45, 0x78, 0x69, 0x66, 0x00, 0x00 }, + &.{ 0xFF, 0xD8, 0xFF, 0xE0 }, + }, + .exts = &.{ ".jpg", ".jpeg", ".jfif" }, + }, + .{ + .typ = .png, + .sigs = &.{&.{ 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A }}, + .exts = &.{".png"}, + }, + .{ + .typ = .gif, + .sigs = &.{ + &.{ 'G', 'I', 'F', '8', '7', 'a' }, + &.{ 'G', 'I', 'F', '8', '9', 'a' }, + }, + .exts = &.{".gif"}, + }, + .{ + .typ = .bmp, + .sigs = &.{&.{ 'B', 'M' }}, + .exts = &.{".bmp"}, + }, + .{ + .typ = .qoi, + .sigs = &.{&.{ 'q', 'o', 'i', 'f' }}, + .exts = &.{".qoi"}, + }, + .{ + .typ = .webp, + .sigs = &.{ + &.{ 0x52, 0x49, 0x46, 0x46, null, null, null, null, 0x57, 0x45, 0x42, 0x50 }, + }, + .exts = &.{".webp"}, + }, +}; + +/// This is a helper for detecting file types based on magic bytes. +/// +/// Ref: https://en.wikipedia.org/wiki/List_of_file_signatures +pub const FileType = enum { + /// JPEG image file. + jpeg, + + /// PNG image file. + png, + + /// GIF image file. + gif, + + /// BMP image file. + bmp, + + /// QOI image file. + qoi, + + /// WebP image file. + webp, + + /// Unknown file format. + unknown, + + /// Detect file type based on the magic bytes + /// at the start of the provided file contents. + pub fn detect(contents: []const u8) FileType { + inline for (type_details) |typ| { + inline for (typ.sigs) |signature| { + if (contents.len >= signature.len) { + for (contents[0..signature.len], signature) |f, sig| { + if (sig) |s| if (f != s) break; + } else { + return typ.typ; + } + } + } + } + return .unknown; + } + + /// Guess file type from its extension. + pub fn guessFromExtension(extension: []const u8) FileType { + inline for (type_details) |typ| { + inline for (typ.exts) |ext| { + if (std.ascii.eqlIgnoreCase(extension, ext)) return typ.typ; + } + } + return .unknown; + } +}; diff --git a/src/font/Atlas.zig b/src/font/Atlas.zig index 327ce225f..969318943 100644 --- a/src/font/Atlas.zig +++ b/src/font/Atlas.zig @@ -50,15 +50,18 @@ modified: std.atomic.Value(usize) = .{ .raw = 0 }, resized: std.atomic.Value(usize) = .{ .raw = 0 }, pub const Format = enum(u8) { + /// 1 byte per pixel grayscale. grayscale = 0, - rgb = 1, - rgba = 2, + /// 3 bytes per pixel BGR. + bgr = 1, + /// 4 bytes per pixel BGRA. + bgra = 2, pub fn depth(self: Format) u8 { return switch (self) { .grayscale => 1, - .rgb => 3, - .rgba => 4, + .bgr => 3, + .bgra => 4, }; } }; @@ -303,7 +306,12 @@ pub fn clear(self: *Atlas) void { } /// Dump the atlas as a PPM to a writer, for debug purposes. -/// Only supports grayscale and rgb atlases. +/// Only supports grayscale and bgr atlases. +/// +/// NOTE: BGR atlases will have the red and blue channels +/// swapped because PPM expects RGB. This would be +/// easy enough to fix so next time someone needs +/// to debug a color atlas they should fix it. pub fn dump(self: Atlas, writer: anytype) !void { try writer.print( \\P{c} @@ -313,7 +321,7 @@ pub fn dump(self: Atlas, writer: anytype) !void { , .{ @as(u8, switch (self.format) { .grayscale => '5', - .rgb => '6', + .bgr => '6', else => { log.err("Unsupported format for dump: {}", .{self.format}); @panic("Cannot dump this atlas format."); @@ -418,8 +426,16 @@ pub const Wasm = struct { // We need to draw pixels so this is format dependent. const buf: []u8 = switch (self.format) { - // RGBA is the native ImageData format - .rgba => self.data, + .bgra => buf: { + // Convert from BGRA to RGBA by swapping every R and B. + var buf: []u8 = try alloc.dupe(u8, self.data); + errdefer alloc.free(buf); + var i: usize = 0; + while (i < self.data.len) : (i += 4) { + std.mem.swap(u8, &buf[i], &buf[i + 2]); + } + break :buf buf; + }, .grayscale => buf: { // Convert from A8 to RGBA so every 4th byte is set to a value. @@ -572,12 +588,12 @@ test "grow" { try testing.expectEqual(@as(u8, 4), atlas.data[atlas.size * 2 + 2]); } -test "writing RGB data" { +test "writing BGR data" { const alloc = testing.allocator; - var atlas = try init(alloc, 32, .rgb); + var atlas = try init(alloc, 32, .bgr); defer atlas.deinit(alloc); - // This is RGB so its 3 bpp + // This is BGR so its 3 bpp const reg = try atlas.reserve(alloc, 1, 2); atlas.set(reg, &[_]u8{ 1, 2, 3, @@ -594,18 +610,18 @@ test "writing RGB data" { try testing.expectEqual(@as(u8, 6), atlas.data[65 * depth + 2]); } -test "grow RGB" { +test "grow BGR" { const alloc = testing.allocator; // Atlas is 4x4 so its a 1px border meaning we only have 2x2 available - var atlas = try init(alloc, 4, .rgb); + var atlas = try init(alloc, 4, .bgr); defer atlas.deinit(alloc); // Get our 2x2, which should be ALL our usable space const reg = try atlas.reserve(alloc, 2, 2); try testing.expectError(Error.AtlasFull, atlas.reserve(alloc, 1, 1)); - // This is RGB so its 3 bpp + // This is BGR so its 3 bpp atlas.set(reg, &[_]u8{ 10, 11, 12, // (0, 0) (x, y) from top-left 13, 14, 15, // (1, 0) diff --git a/src/font/SharedGrid.zig b/src/font/SharedGrid.zig index 35770f920..dcfa0a551 100644 --- a/src/font/SharedGrid.zig +++ b/src/font/SharedGrid.zig @@ -40,7 +40,7 @@ const log = std.log.scoped(.font_shared_grid); codepoints: std.AutoHashMapUnmanaged(CodepointKey, ?Collection.Index) = .{}, /// Cache for glyph renders into the atlas. -glyphs: std.AutoHashMapUnmanaged(GlyphKey, Render) = .{}, +glyphs: std.HashMapUnmanaged(GlyphKey, Render, GlyphKey.Context, 80) = .{}, /// The texture atlas to store renders in. The Glyph data in the glyphs /// cache is dependent on the atlas matching. @@ -79,7 +79,7 @@ pub fn init( var atlas_grayscale = try Atlas.init(alloc, 512, .grayscale); errdefer atlas_grayscale.deinit(alloc); - var atlas_color = try Atlas.init(alloc, 512, .rgba); + var atlas_color = try Atlas.init(alloc, 512, .bgra); errdefer atlas_color.deinit(alloc); var result: SharedGrid = .{ @@ -307,6 +307,39 @@ const GlyphKey = struct { index: Collection.Index, glyph: u32, opts: RenderOptions, + + const Context = struct { + pub fn hash(_: Context, key: GlyphKey) u64 { + return @bitCast(Packed.from(key)); + } + + pub fn eql(_: Context, a: GlyphKey, b: GlyphKey) bool { + return Packed.from(a) == Packed.from(b); + } + }; + + const Packed = packed struct(u64) { + index: Collection.Index, + glyph: u32, + opts: packed struct(u16) { + cell_width: u2, + thicken: bool, + thicken_strength: u8, + _padding: u5 = 0, + }, + + inline fn from(key: GlyphKey) Packed { + return .{ + .index = key.index, + .glyph = key.glyph, + .opts = .{ + .cell_width = key.opts.cell_width orelse 0, + .thicken = key.opts.thicken, + .thicken_strength = key.opts.thicken_strength, + }, + }; + } + }; }; const TestMode = enum { normal }; diff --git a/src/font/face/freetype.zig b/src/font/face/freetype.zig index bf86b88de..accb891a4 100644 --- a/src/font/face/freetype.zig +++ b/src/font/face/freetype.zig @@ -391,7 +391,7 @@ pub const Face = struct { const format: ?font.Atlas.Format = switch (bitmap_ft.pixel_mode) { freetype.c.FT_PIXEL_MODE_MONO => null, freetype.c.FT_PIXEL_MODE_GRAY => .grayscale, - freetype.c.FT_PIXEL_MODE_BGRA => .rgba, + freetype.c.FT_PIXEL_MODE_BGRA => .bgra, else => { log.warn("glyph={} pixel mode={}", .{ glyph_index, bitmap_ft.pixel_mode }); @panic("unsupported pixel mode"); @@ -925,7 +925,7 @@ test "color emoji" { var lib = try Library.init(alloc); defer lib.deinit(); - var atlas = try font.Atlas.init(alloc, 512, .rgba); + var atlas = try font.Atlas.init(alloc, 512, .bgra); defer atlas.deinit(alloc); var ft_font = try Face.init( @@ -973,14 +973,14 @@ test "color emoji" { } } -test "mono to rgba" { +test "mono to bgra" { const alloc = testing.allocator; const testFont = font.embedded.emoji; var lib = try Library.init(alloc); defer lib.deinit(); - var atlas = try font.Atlas.init(alloc, 512, .rgba); + var atlas = try font.Atlas.init(alloc, 512, .bgra); defer atlas.deinit(alloc); var ft_font = try Face.init(lib, testFont, .{ .size = .{ .points = 12, .xdpi = 72, .ydpi = 72 } }); diff --git a/src/font/shape.zig b/src/font/shape.zig index cc67fc7a0..5e1e30eec 100644 --- a/src/font/shape.zig +++ b/src/font/shape.zig @@ -2,6 +2,9 @@ const builtin = @import("builtin"); const options = @import("main.zig").options; const run = @import("shaper/run.zig"); const feature = @import("shaper/feature.zig"); +const configpkg = @import("../config.zig"); +const terminal = @import("../terminal/main.zig"); +const SharedGrid = @import("main.zig").SharedGrid; pub const noop = @import("shaper/noop.zig"); pub const harfbuzz = @import("shaper/harfbuzz.zig"); pub const coretext = @import("shaper/coretext.zig"); @@ -61,6 +64,38 @@ pub const Options = struct { features: []const []const u8 = &.{}, }; +/// Options for runIterator. +pub const RunOptions = struct { + /// The font state for the terminal screen. This is mutable because + /// cached values may be updated during shaping. + grid: *SharedGrid, + + /// The terminal screen to shape. + screen: *const terminal.Screen, + + /// The row within the screen to shape. This row must exist within + /// screen; it is not validated. + row: terminal.Pin, + + /// The selection boundaries. This is used to break shaping on + /// selection boundaries. This can be disabled by setting this to + /// null. + selection: ?terminal.Selection = null, + + /// The cursor position within this row. This is used to break shaping + /// on cursor boundaries. This can be disabled by setting this to + /// null. + cursor_x: ?usize = null, + + /// Apply the font break configuration to the run. + pub fn applyBreakConfig( + self: *RunOptions, + config: configpkg.FontShapingBreak, + ) void { + if (!config.cursor) self.cursor_x = null; + } +}; + test { _ = Cache; _ = Shaper; diff --git a/src/font/shaper/coretext.zig b/src/font/shaper/coretext.zig index 8e2c45c69..1fd9719bb 100644 --- a/src/font/shaper/coretext.zig +++ b/src/font/shaper/coretext.zig @@ -7,6 +7,7 @@ const trace = @import("tracy").trace; const font = @import("../main.zig"); const os = @import("../../os/main.zig"); const terminal = @import("../../terminal/main.zig"); +const config = @import("../../config.zig"); const Feature = font.shape.Feature; const FeatureList = font.shape.FeatureList; const default_features = font.shape.default_features; @@ -288,19 +289,11 @@ pub const Shaper = struct { pub fn runIterator( self: *Shaper, - grid: *SharedGrid, - screen: *const terminal.Screen, - row: terminal.Pin, - selection: ?terminal.Selection, - cursor_x: ?usize, + opts: font.shape.RunOptions, ) font.shape.RunIterator { return .{ .hooks = .{ .shaper = self }, - .grid = grid, - .screen = screen, - .row = row, - .selection = selection, - .cursor_x = cursor_x, + .opts = opts, }; } @@ -594,13 +587,11 @@ test "run iterator" { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |_| count += 1; try testing.expectEqual(@as(usize, 1), count); @@ -613,13 +604,11 @@ test "run iterator" { try screen.testWriteString("ABCD EFG"); var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |_| count += 1; try testing.expectEqual(@as(usize, 1), count); @@ -633,13 +622,11 @@ test "run iterator" { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |_| count += 1; try testing.expectEqual(@as(usize, 3), count); @@ -654,13 +641,11 @@ test "run iterator" { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |_| count += 1; try testing.expectEqual(@as(usize, 2), count); @@ -701,13 +686,11 @@ test "run iterator: empty cells with background set" { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); { const run = (try it.next(alloc)).?; const cells = try shaper.shape(run); @@ -737,13 +720,11 @@ test "shape" { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -772,13 +753,11 @@ test "shape nerd fonts" { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -800,13 +779,11 @@ test "shape inconsolata ligs" { try screen.testWriteString(">="); var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -825,13 +802,11 @@ test "shape inconsolata ligs" { try screen.testWriteString("==="); var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -858,13 +833,11 @@ test "shape monaspace ligs" { try screen.testWriteString("==="); var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -892,13 +865,11 @@ test "shape left-replaced lig in last run" { try screen.testWriteString("!=="); var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -926,13 +897,11 @@ test "shape left-replaced lig in early run" { try screen.testWriteString("!==X"); var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); const run = (try it.next(alloc)).?; @@ -957,13 +926,11 @@ test "shape U+3C9 with JB Mono" { try screen.testWriteString("\u{03C9} foo"); var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var run_count: usize = 0; var cell_count: usize = 0; @@ -990,13 +957,11 @@ test "shape emoji width" { try screen.testWriteString("👍"); var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -1040,13 +1005,11 @@ test "shape emoji width long" { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 1 } }).?, - null, - null, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 1 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -1076,13 +1039,11 @@ test "shape variation selector VS15" { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -1111,13 +1072,11 @@ test "shape variation selector VS16" { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -1143,13 +1102,11 @@ test "shape with empty cells in between" { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -1181,13 +1138,11 @@ test "shape Chinese characters" { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -1221,13 +1176,11 @@ test "shape box glyphs" { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -1257,17 +1210,16 @@ test "shape selection boundary" { { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - terminal.Selection.init( + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .selection = terminal.Selection.init( screen.pages.pin(.{ .active = .{ .x = 0, .y = 0 } }).?, screen.pages.pin(.{ .active = .{ .x = screen.pages.cols - 1, .y = 0 } }).?, false, ), - null, - ); + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -1280,17 +1232,16 @@ test "shape selection boundary" { { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - terminal.Selection.init( + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .selection = terminal.Selection.init( screen.pages.pin(.{ .active = .{ .x = 2, .y = 0 } }).?, screen.pages.pin(.{ .active = .{ .x = screen.pages.cols - 1, .y = 0 } }).?, false, ), - null, - ); + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -1303,17 +1254,16 @@ test "shape selection boundary" { { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - terminal.Selection.init( + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .selection = terminal.Selection.init( screen.pages.pin(.{ .active = .{ .x = 0, .y = 0 } }).?, screen.pages.pin(.{ .active = .{ .x = 3, .y = 0 } }).?, false, ), - null, - ); + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -1326,17 +1276,16 @@ test "shape selection boundary" { { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - terminal.Selection.init( + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .selection = terminal.Selection.init( screen.pages.pin(.{ .active = .{ .x = 1, .y = 0 } }).?, screen.pages.pin(.{ .active = .{ .x = 3, .y = 0 } }).?, false, ), - null, - ); + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -1349,17 +1298,16 @@ test "shape selection boundary" { { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - terminal.Selection.init( + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .selection = terminal.Selection.init( screen.pages.pin(.{ .active = .{ .x = 1, .y = 0 } }).?, screen.pages.pin(.{ .active = .{ .x = 1, .y = 0 } }).?, false, ), - null, - ); + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -1385,13 +1333,11 @@ test "shape cursor boundary" { { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -1400,61 +1346,111 @@ test "shape cursor boundary" { try testing.expectEqual(@as(usize, 1), count); } - // Cursor at index 0 is two runs { - // Get our run iterator - var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - 0, - ); - var count: usize = 0; - while (try it.next(alloc)) |run| { - count += 1; - _ = try shaper.shape(run); + // Cursor at index 0 is two runs + { + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cursor_x = 0, + }); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + _ = try shaper.shape(run); + } + try testing.expectEqual(@as(usize, 2), count); + } + // And without cursor splitting remains one + { + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + _ = try shaper.shape(run); + } + try testing.expectEqual(@as(usize, 1), count); } - try testing.expectEqual(@as(usize, 2), count); } - // Cursor at index 1 is three runs { - // Get our run iterator - var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - 1, - ); - var count: usize = 0; - while (try it.next(alloc)) |run| { - count += 1; - _ = try shaper.shape(run); + // Cursor at index 1 is three runs + { + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cursor_x = 1, + }); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + _ = try shaper.shape(run); + } + try testing.expectEqual(@as(usize, 3), count); + } + // And without cursor splitting remains one + { + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + _ = try shaper.shape(run); + } + try testing.expectEqual(@as(usize, 1), count); } - try testing.expectEqual(@as(usize, 3), count); } - - // Cursor at last col is two runs { - // Get our run iterator - var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - 9, - ); - var count: usize = 0; - while (try it.next(alloc)) |run| { - count += 1; - _ = try shaper.shape(run); + // Cursor at last col is two runs + { + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cursor_x = 9, + }); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + _ = try shaper.shape(run); + } + try testing.expectEqual(@as(usize, 2), count); + } + // And without cursor splitting remains one + { + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + _ = try shaper.shape(run); + } + try testing.expectEqual(@as(usize, 1), count); } - try testing.expectEqual(@as(usize, 2), count); } } @@ -1474,13 +1470,11 @@ test "shape cursor boundary and colored emoji" { { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -1493,13 +1487,12 @@ test "shape cursor boundary and colored emoji" { { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - 0, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cursor_x = 0, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -1510,13 +1503,42 @@ test "shape cursor boundary and colored emoji" { { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - 1, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + _ = try shaper.shape(run); + } + try testing.expectEqual(@as(usize, 1), count); + } + { + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cursor_x = 1, + }); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + _ = try shaper.shape(run); + } + try testing.expectEqual(@as(usize, 1), count); + } + { + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -1540,13 +1562,11 @@ test "shape cell attribute change" { try screen.testWriteString(">="); var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -1564,13 +1584,11 @@ test "shape cell attribute change" { try screen.testWriteString("="); var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -1589,13 +1607,11 @@ test "shape cell attribute change" { try screen.testWriteString("="); var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -1614,13 +1630,11 @@ test "shape cell attribute change" { try screen.testWriteString("="); var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -1638,13 +1652,11 @@ test "shape cell attribute change" { try screen.testWriteString("="); var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -1678,13 +1690,11 @@ test "shape high plane sprite font codepoint" { try screen.testWriteString("\u{1FB70}"); var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); // We should get one run const run = (try it.next(alloc)).?; // The run state should have the UTF-16 encoding of the character. diff --git a/src/font/shaper/harfbuzz.zig b/src/font/shaper/harfbuzz.zig index 361cbbe93..4209f795c 100644 --- a/src/font/shaper/harfbuzz.zig +++ b/src/font/shaper/harfbuzz.zig @@ -4,6 +4,7 @@ const Allocator = std.mem.Allocator; const harfbuzz = @import("harfbuzz"); const font = @import("../main.zig"); const terminal = @import("../../terminal/main.zig"); +const config = @import("../../config.zig"); const Feature = font.shape.Feature; const FeatureList = font.shape.FeatureList; const default_features = font.shape.default_features; @@ -89,19 +90,11 @@ pub const Shaper = struct { /// and assume the y value matches. pub fn runIterator( self: *Shaper, - grid: *SharedGrid, - screen: *const terminal.Screen, - row: terminal.Pin, - selection: ?terminal.Selection, - cursor_x: ?usize, + opts: font.shape.RunOptions, ) font.shape.RunIterator { return .{ .hooks = .{ .shaper = self }, - .grid = grid, - .screen = screen, - .row = row, - .selection = selection, - .cursor_x = cursor_x, + .opts = opts, }; } @@ -225,13 +218,11 @@ test "run iterator" { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |_| count += 1; try testing.expectEqual(@as(usize, 1), count); @@ -244,13 +235,11 @@ test "run iterator" { try screen.testWriteString("ABCD EFG"); var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |_| count += 1; try testing.expectEqual(@as(usize, 1), count); @@ -264,13 +253,11 @@ test "run iterator" { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |_| { count += 1; @@ -316,13 +303,11 @@ test "run iterator: empty cells with background set" { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); { const run = (try it.next(alloc)).?; try testing.expectEqual(@as(u32, 3), shaper.hb_buf.getLength()); @@ -353,13 +338,11 @@ test "shape" { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -382,13 +365,11 @@ test "shape inconsolata ligs" { try screen.testWriteString(">="); var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -407,13 +388,11 @@ test "shape inconsolata ligs" { try screen.testWriteString("==="); var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -440,13 +419,11 @@ test "shape monaspace ligs" { try screen.testWriteString("==="); var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -476,13 +453,11 @@ test "shape arabic forced LTR" { try screen.testWriteString(@embedFile("testdata/arabic.txt")); var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -513,13 +488,11 @@ test "shape emoji width" { try screen.testWriteString("👍"); var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -565,13 +538,11 @@ test "shape emoji width long" { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 1 } }).?, - null, - null, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 1 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -603,13 +574,11 @@ test "shape variation selector VS15" { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -640,13 +609,11 @@ test "shape variation selector VS16" { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -674,13 +641,11 @@ test "shape with empty cells in between" { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -712,13 +677,11 @@ test "shape Chinese characters" { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -752,13 +715,11 @@ test "shape box glyphs" { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -789,17 +750,16 @@ test "shape selection boundary" { { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - terminal.Selection.init( + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .selection = terminal.Selection.init( screen.pages.pin(.{ .active = .{ .x = 0, .y = 0 } }).?, screen.pages.pin(.{ .active = .{ .x = screen.pages.cols - 1, .y = 0 } }).?, false, ), - null, - ); + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -812,17 +772,16 @@ test "shape selection boundary" { { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - terminal.Selection.init( + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .selection = terminal.Selection.init( screen.pages.pin(.{ .active = .{ .x = 2, .y = 0 } }).?, screen.pages.pin(.{ .active = .{ .x = screen.pages.cols - 1, .y = 0 } }).?, false, ), - null, - ); + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -835,17 +794,16 @@ test "shape selection boundary" { { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - terminal.Selection.init( + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .selection = terminal.Selection.init( screen.pages.pin(.{ .active = .{ .x = 0, .y = 0 } }).?, screen.pages.pin(.{ .active = .{ .x = 3, .y = 0 } }).?, false, ), - null, - ); + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -858,17 +816,16 @@ test "shape selection boundary" { { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - terminal.Selection.init( + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .selection = terminal.Selection.init( screen.pages.pin(.{ .active = .{ .x = 1, .y = 0 } }).?, screen.pages.pin(.{ .active = .{ .x = 3, .y = 0 } }).?, false, ), - null, - ); + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -881,17 +838,16 @@ test "shape selection boundary" { { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - terminal.Selection.init( + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .selection = terminal.Selection.init( screen.pages.pin(.{ .active = .{ .x = 1, .y = 0 } }).?, screen.pages.pin(.{ .active = .{ .x = 1, .y = 0 } }).?, false, ), - null, - ); + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -917,13 +873,11 @@ test "shape cursor boundary" { { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -932,61 +886,111 @@ test "shape cursor boundary" { try testing.expectEqual(@as(usize, 1), count); } - // Cursor at index 0 is two runs { - // Get our run iterator - var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - 0, - ); - var count: usize = 0; - while (try it.next(alloc)) |run| { - count += 1; - _ = try shaper.shape(run); + // Cursor at index 0 is two runs + { + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cursor_x = 0, + }); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + _ = try shaper.shape(run); + } + try testing.expectEqual(@as(usize, 2), count); + } + // And without cursor splitting remains one + { + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + _ = try shaper.shape(run); + } + try testing.expectEqual(@as(usize, 1), count); } - try testing.expectEqual(@as(usize, 2), count); } - // Cursor at index 1 is three runs { - // Get our run iterator - var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - 1, - ); - var count: usize = 0; - while (try it.next(alloc)) |run| { - count += 1; - _ = try shaper.shape(run); + // Cursor at index 1 is three runs + { + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cursor_x = 1, + }); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + _ = try shaper.shape(run); + } + try testing.expectEqual(@as(usize, 3), count); + } + // And without cursor splitting remains one + { + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + _ = try shaper.shape(run); + } + try testing.expectEqual(@as(usize, 1), count); } - try testing.expectEqual(@as(usize, 3), count); } - - // Cursor at last col is two runs { - // Get our run iterator - var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - 9, - ); - var count: usize = 0; - while (try it.next(alloc)) |run| { - count += 1; - _ = try shaper.shape(run); + // Cursor at last col is two runs + { + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cursor_x = 9, + }); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + _ = try shaper.shape(run); + } + try testing.expectEqual(@as(usize, 2), count); + } + // And without cursor splitting remains one + { + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + _ = try shaper.shape(run); + } + try testing.expectEqual(@as(usize, 1), count); } - try testing.expectEqual(@as(usize, 2), count); } } @@ -1006,13 +1010,11 @@ test "shape cursor boundary and colored emoji" { { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -1025,13 +1027,12 @@ test "shape cursor boundary and colored emoji" { { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - 0, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cursor_x = 0, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -1042,13 +1043,42 @@ test "shape cursor boundary and colored emoji" { { // Get our run iterator var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - 1, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + _ = try shaper.shape(run); + } + try testing.expectEqual(@as(usize, 1), count); + } + { + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + .cursor_x = 1, + }); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + _ = try shaper.shape(run); + } + try testing.expectEqual(@as(usize, 1), count); + } + { + // Get our run iterator + var shaper = &testdata.shaper; + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -1072,13 +1102,11 @@ test "shape cell attribute change" { try screen.testWriteString(">="); var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -1096,13 +1124,11 @@ test "shape cell attribute change" { try screen.testWriteString("="); var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -1121,13 +1147,11 @@ test "shape cell attribute change" { try screen.testWriteString("="); var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -1146,13 +1170,11 @@ test "shape cell attribute change" { try screen.testWriteString("="); var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; @@ -1170,13 +1192,11 @@ test "shape cell attribute change" { try screen.testWriteString("="); var shaper = &testdata.shaper; - var it = shaper.runIterator( - testdata.grid, - &screen, - screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, - null, - null, - ); + var it = shaper.runIterator(.{ + .grid = testdata.grid, + .screen = &screen, + .row = screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + }); var count: usize = 0; while (try it.next(alloc)) |run| { count += 1; diff --git a/src/font/shaper/noop.zig b/src/font/shaper/noop.zig index f8988f4ee..8723071d7 100644 --- a/src/font/shaper/noop.zig +++ b/src/font/shaper/noop.zig @@ -70,19 +70,11 @@ pub const Shaper = struct { pub fn runIterator( self: *Shaper, - grid: *SharedGrid, - screen: *const terminal.Screen, - row: terminal.Pin, - selection: ?terminal.Selection, - cursor_x: ?usize, + opts: font.shape.RunOptions, ) font.shape.RunIterator { return .{ .hooks = .{ .shaper = self }, - .grid = grid, - .screen = screen, - .row = row, - .selection = selection, - .cursor_x = cursor_x, + .opts = opts, }; } diff --git a/src/font/shaper/run.zig b/src/font/shaper/run.zig index 18ddd4b56..92e629e19 100644 --- a/src/font/shaper/run.zig +++ b/src/font/shaper/run.zig @@ -35,15 +35,11 @@ pub const TextRun = struct { /// RunIterator is an iterator that yields text runs. pub const RunIterator = struct { hooks: font.Shaper.RunIteratorHook, - grid: *font.SharedGrid, - screen: *const terminal.Screen, - row: terminal.Pin, - selection: ?terminal.Selection = null, - cursor_x: ?usize = null, + opts: shape.RunOptions, i: usize = 0, pub fn next(self: *RunIterator, alloc: Allocator) !?TextRun { - const cells = self.row.cells(.all); + const cells = self.opts.row.cells(.all); // Trim the right side of a row that might be empty const max: usize = max: { @@ -58,7 +54,7 @@ pub const RunIterator = struct { // Invisible cells don't have any glyphs rendered, // so we explicitly skip them in the shaping process. while (self.i < max and - self.row.style(&cells[self.i]).flags.invisible) + self.opts.row.style(&cells[self.i]).flags.invisible) { self.i += 1; } @@ -76,7 +72,7 @@ pub const RunIterator = struct { var hasher = Hasher.init(0); // Let's get our style that we'll expect for the run. - const style = self.row.style(&cells[self.i]); + const style = self.opts.row.style(&cells[self.i]); // Go through cell by cell and accumulate while we build our run. var j: usize = self.i; @@ -86,9 +82,9 @@ pub const RunIterator = struct { // If we have a selection and we're at a boundary point, then // we break the run here. - if (self.selection) |unordered_sel| { + if (self.opts.selection) |unordered_sel| { if (j > self.i) { - const sel = unordered_sel.ordered(self.screen, .forward); + const sel = unordered_sel.ordered(self.opts.screen, .forward); const start_x = sel.start().x; const end_x = sel.end().x; @@ -142,7 +138,7 @@ pub const RunIterator = struct { // The style is different. We allow differing background // styles but any other change results in a new run. const c1 = comparableStyle(style); - const c2 = comparableStyle(self.row.style(&cells[j])); + const c2 = comparableStyle(self.opts.row.style(&cells[j])); if (!c1.eql(c2)) break; } @@ -162,7 +158,7 @@ pub const RunIterator = struct { const presentation: ?font.Presentation = if (cell.hasGrapheme()) p: { // We only check the FIRST codepoint because I believe the // presentation format must be directly adjacent to the codepoint. - const cps = self.row.grapheme(cell) orelse break :p null; + const cps = self.opts.row.grapheme(cell) orelse break :p null; assert(cps.len > 0); if (cps[0] == 0xFE0E) break :p .text; if (cps[0] == 0xFE0F) break :p .emoji; @@ -186,7 +182,7 @@ pub const RunIterator = struct { // joiners will show the joiners allowing you to modify the // emoji. if (!cell.hasGrapheme()) { - if (self.cursor_x) |cursor_x| { + if (self.opts.cursor_x) |cursor_x| { // Exactly: self.i is the cursor and we iterated once. This // means that we started exactly at the cursor and did at // exactly one iteration. Why exactly one? Because we may @@ -227,7 +223,7 @@ pub const RunIterator = struct { // Otherwise we need a fallback character. Prefer the // official replacement character. - if (try self.grid.getIndex( + if (try self.opts.grid.getIndex( alloc, 0xFFFD, // replacement char font_style, @@ -235,7 +231,7 @@ pub const RunIterator = struct { )) |idx| break :font_info .{ .idx = idx, .fallback = 0xFFFD }; // Fallback to space - if (try self.grid.getIndex( + if (try self.opts.grid.getIndex( alloc, ' ', font_style, @@ -273,7 +269,7 @@ pub const RunIterator = struct { @intCast(cluster), ); if (cell.hasGrapheme()) { - const cps = self.row.grapheme(cell).?; + const cps = self.opts.row.grapheme(cell).?; for (cps) |cp| { // Do not send presentation modifiers if (cp == 0xFE0E or cp == 0xFE0F) continue; @@ -298,7 +294,7 @@ pub const RunIterator = struct { .hash = hasher.final(), .offset = @intCast(self.i), .cells = @intCast(j - self.i), - .grid = self.grid, + .grid = self.opts.grid, .font_index = current_font, }; } @@ -326,7 +322,7 @@ pub const RunIterator = struct { cell.codepoint() == 0 or cell.codepoint() == terminal.kitty.graphics.unicode.placeholder) { - return try self.grid.getIndex( + return try self.opts.grid.getIndex( alloc, ' ', style, @@ -336,7 +332,7 @@ pub const RunIterator = struct { // Get the font index for the primary codepoint. const primary_cp: u32 = cell.codepoint(); - const primary = try self.grid.getIndex( + const primary = try self.opts.grid.getIndex( alloc, primary_cp, style, @@ -349,7 +345,7 @@ pub const RunIterator = struct { // If this is a grapheme, we need to find a font that supports // all of the codepoints in the grapheme. - const cps = self.row.grapheme(cell) orelse return primary; + const cps = self.opts.row.grapheme(cell) orelse return primary; var candidates = try std.ArrayList(font.Collection.Index).initCapacity(alloc, cps.len + 1); defer candidates.deinit(); candidates.appendAssumeCapacity(primary); @@ -365,7 +361,7 @@ pub const RunIterator = struct { // to support the base presentation, since it is common for emoji // fonts to support the base emoji with emoji presentation but not // certain ZWJ-combined characters like the male and female signs. - const idx = try self.grid.getIndex( + const idx = try self.opts.grid.getIndex( alloc, cp, style, @@ -376,11 +372,11 @@ pub const RunIterator = struct { // We need to find a candidate that has ALL of our codepoints for (candidates.items) |idx| { - if (!self.grid.hasCodepoint(idx, primary_cp, presentation)) continue; + if (!self.opts.grid.hasCodepoint(idx, primary_cp, presentation)) continue; for (cps) |cp| { // Ignore Emoji ZWJs if (cp == 0xFE0E or cp == 0xFE0F or cp == 0x200D) continue; - if (!self.grid.hasCodepoint(idx, cp, null)) break; + if (!self.opts.grid.hasCodepoint(idx, cp, null)) break; } else { // If the while completed, then we have a candidate that // supports all of our codepoints. diff --git a/src/font/shaper/web_canvas.zig b/src/font/shaper/web_canvas.zig index f38ab885a..4ed4b7db6 100644 --- a/src/font/shaper/web_canvas.zig +++ b/src/font/shaper/web_canvas.zig @@ -61,17 +61,11 @@ pub const Shaper = struct { /// for a Shaper struct since they share state. pub fn runIterator( self: *Shaper, - group: *font.GroupCache, - row: terminal.Screen.Row, - selection: ?terminal.Selection, - cursor_x: ?usize, + opts: font.shape.RunOptions, ) font.shape.RunIterator { return .{ .hooks = .{ .shaper = self }, - .group = group, - .row = row, - .selection = selection, - .cursor_x = cursor_x, + .opts = opts, }; } diff --git a/src/global.zig b/src/global.zig index d11dd775b..668d2faec 100644 --- a/src/global.zig +++ b/src/global.zig @@ -9,6 +9,7 @@ const harfbuzz = @import("harfbuzz"); const oni = @import("oniguruma"); const crash = @import("crash/main.zig"); const renderer = @import("renderer.zig"); +const apprt = @import("apprt.zig"); /// We export the xev backend we want to use so that the rest of /// Ghostty can import this once and have access to the proper @@ -35,7 +36,7 @@ pub const GlobalState = struct { /// The app resources directory, equivalent to zig-out/share when we build /// from source. This is null if we can't detect it. - resources_dir: ?[]const u8, + resources_dir: internal_os.ResourcesDir, /// Where logging should go pub const Logging = union(enum) { @@ -62,7 +63,7 @@ pub const GlobalState = struct { .action = null, .logging = .{ .stderr = {} }, .rlimits = .{}, - .resources_dir = null, + .resources_dir = .{}, }; errdefer self.deinit(); @@ -170,11 +171,11 @@ pub const GlobalState = struct { // Find our resources directory once for the app so every launch // hereafter can use this cached value. - self.resources_dir = try internal_os.resourcesDir(self.alloc); - errdefer if (self.resources_dir) |dir| self.alloc.free(dir); + self.resources_dir = try apprt.runtime.resourcesDir(self.alloc); + errdefer self.resources_dir.deinit(self.alloc); // Setup i18n - if (self.resources_dir) |v| internal_os.i18n.init(v) catch |err| { + if (self.resources_dir.app()) |v| internal_os.i18n.init(v) catch |err| { std.log.warn("failed to init i18n, translations will not be available err={}", .{err}); }; } @@ -182,7 +183,7 @@ pub const GlobalState = struct { /// Cleans up the global state. This doesn't _need_ to be called but /// doing so in dev modes will check for memory leaks. pub fn deinit(self: *GlobalState) void { - if (self.resources_dir) |dir| self.alloc.free(dir); + self.resources_dir.deinit(self.alloc); // Flush our crash logs crash.deinit(); diff --git a/src/input/Binding.zig b/src/input/Binding.zig index cccf12ac4..7cdb8047c 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -379,6 +379,10 @@ pub const Action = union(enum) { /// /// Valid actions are: /// + /// - `copy` + /// + /// Copy the file path into the clipboard. + /// /// - `paste` /// /// Paste the file path into the terminal. @@ -813,6 +817,7 @@ pub const Action = union(enum) { }; pub const WriteScreenAction = enum { + copy, paste, open, }; diff --git a/src/input/command.zig b/src/input/command.zig index 94fbf56a5..693d5c8d4 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -18,7 +18,7 @@ const Action = @import("Binding.zig").Action; pub const Command = struct { action: Action, title: [:0]const u8, - description: [:0]const u8, + description: [:0]const u8 = "", /// ghostty_command_s pub const C = extern struct { @@ -28,6 +28,21 @@ pub const Command = struct { description: [*:0]const u8, }; + pub fn clone(self: *const Command, alloc: Allocator) Allocator.Error!Command { + return .{ + .action = try self.action.clone(alloc), + .title = try alloc.dupeZ(u8, self.title), + .description = try alloc.dupeZ(u8, self.description), + }; + } + + pub fn equal(self: Command, other: Command) bool { + if (self.action.hash() != other.action.hash()) return false; + if (!std.mem.eql(u8, self.title, other.title)) return false; + if (!std.mem.eql(u8, self.description, other.description)) return false; + return true; + } + /// Convert this command to a C struct. pub fn comptimeCval(self: Command) C { assert(@inComptime()); @@ -189,6 +204,11 @@ fn actionCommands(action: Action.Key) []const Command { }}, .write_screen_file => comptime &.{ + .{ + .action = .{ .write_screen_file = .copy }, + .title = "Copy Screen to Temporary File and Copy Path", + .description = "Copy the screen contents to a temporary file and copy the path to the clipboard.", + }, .{ .action = .{ .write_screen_file = .paste }, .title = "Copy Screen to Temporary File and Paste Path", @@ -202,6 +222,11 @@ fn actionCommands(action: Action.Key) []const Command { }, .write_selection_file => comptime &.{ + .{ + .action = .{ .write_selection_file = .copy }, + .title = "Copy Selection to Temporary File and Copy Path", + .description = "Copy the selection contents to a temporary file and copy the path to the clipboard.", + }, .{ .action = .{ .write_selection_file = .paste }, .title = "Copy Selection to Temporary File and Paste Path", diff --git a/src/main_ghostty.zig b/src/main_ghostty.zig index 985c6c9bd..567eec5f9 100644 --- a/src/main_ghostty.zig +++ b/src/main_ghostty.zig @@ -98,11 +98,12 @@ pub fn main() !MainReturn { } // Create our app state - var app = try App.create(alloc); + const app: *App = try App.create(alloc); defer app.destroy(); // Create our runtime app - var app_runtime = try apprt.App.init(app, .{}); + var app_runtime: apprt.App = undefined; + try app_runtime.init(app, .{}); defer app_runtime.terminate(); // Since - by definition - there are no surfaces when first started, the diff --git a/src/os/hostname.zig b/src/os/hostname.zig index 22f29ceff..a75ca1cbb 100644 --- a/src/os/hostname.zig +++ b/src/os/hostname.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const builtin = @import("builtin"); const posix = std.posix; pub const HostnameParsingError = error{ @@ -6,6 +7,96 @@ pub const HostnameParsingError = error{ NoSpaceLeft, }; +pub const UrlParsingError = std.Uri.ParseError || error{ + HostnameIsNotMacAddress, + NoSchemeProvided, +}; + +const mac_address_length = 17; + +fn isUriPathSeparator(c: u8) bool { + return switch (c) { + '?', '#' => true, + else => false, + }; +} + +fn isValidMacAddress(mac_address: []const u8) bool { + // A valid mac address has 6 two-character components with 5 colons, e.g. 12:34:56:ab:cd:ef. + if (mac_address.len != 17) { + return false; + } + + for (mac_address, 0..) |c, i| { + if ((i + 1) % 3 == 0) { + if (c != ':') { + return false; + } + } else if (!std.mem.containsAtLeastScalar(u8, "0123456789ABCDEFabcdef", 1, c)) { + return false; + } + } + + return true; +} + +/// Parses the provided url to a `std.Uri` struct. This is very specific to getting hostname and +/// path information for Ghostty's PWD reporting functionality. Takes into account that on macOS +/// the url passed to this function might have a mac address as its hostname and parses it +/// correctly. +pub fn parseUrl(url: []const u8) UrlParsingError!std.Uri { + 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 + // address are non-digits, e.g. 'ff', and thus an invalid port. + // + // Example: file://12:34:56:78:90:12/path/to/file + if (e != error.InvalidPort) return e; + + const url_without_scheme_start = std.mem.indexOf(u8, url, "://") orelse { + return error.NoSchemeProvided; + }; + const scheme = url[0..url_without_scheme_start]; + const url_without_scheme = url[url_without_scheme_start + 3 ..]; + + // The first '/' after the scheme marks the end of the hostname. If the first '/' + // following the end of the scheme is not at the right position this is not a + // valid mac address. + if (url_without_scheme.len != mac_address_length and + std.mem.indexOfScalarPos(u8, url_without_scheme, 0, '/') != mac_address_length) + { + return error.HostnameIsNotMacAddress; + } + + // At this point we may have a mac address as the hostname. + const mac_address = url_without_scheme[0..mac_address_length]; + + if (!isValidMacAddress(mac_address)) { + return error.HostnameIsNotMacAddress; + } + + var uri_path_end_idx: usize = mac_address_length; + while (uri_path_end_idx < url_without_scheme.len and + !isUriPathSeparator(url_without_scheme[uri_path_end_idx])) + { + uri_path_end_idx += 1; + } + + // Same compliance factor as std.Uri.parse(), i.e. not at all compliant with the URI + // spec. + return .{ + .scheme = scheme, + .host = .{ .percent_encoded = mac_address }, + .path = .{ + .percent_encoded = url_without_scheme[mac_address_length..uri_path_end_idx], + }, + }; + }; +} + /// Print the hostname from a file URI into a buffer. pub fn bufPrintHostnameFromFileUri( buf: []u8, @@ -70,6 +161,101 @@ pub fn isLocalHostname(hostname: []const u8) LocalHostnameValidationError!bool { return std.mem.eql(u8, hostname, ourHostname); } +test parseUrl { + // 1. Typical hostnames. + + var uri = try parseUrl("file://personal.computer/home/test/"); + + try std.testing.expectEqualStrings("file", uri.scheme); + try std.testing.expectEqualStrings("personal.computer", uri.host.?.percent_encoded); + try std.testing.expectEqualStrings("/home/test/", uri.path.percent_encoded); + try std.testing.expect(uri.port == null); + + uri = try parseUrl("kitty-shell-cwd://personal.computer/home/test/"); + + try std.testing.expectEqualStrings("kitty-shell-cwd", uri.scheme); + try std.testing.expectEqualStrings("personal.computer", uri.host.?.percent_encoded); + try std.testing.expectEqualStrings("/home/test/", uri.path.percent_encoded); + try std.testing.expect(uri.port == null); + + // 2. Hostnames that are mac addresses. + + // Numerical mac addresses. + + uri = try parseUrl("file://12:34:56:78:90:12/home/test/"); + + try std.testing.expectEqualStrings("file", uri.scheme); + try std.testing.expectEqualStrings("12:34:56:78:90", uri.host.?.percent_encoded); + try std.testing.expectEqualStrings("/home/test/", uri.path.percent_encoded); + try std.testing.expect(uri.port == 12); + + uri = try parseUrl("kitty-shell-cwd://12:34:56:78:90:12/home/test/"); + + try std.testing.expectEqualStrings("kitty-shell-cwd", uri.scheme); + try std.testing.expectEqualStrings("12:34:56:78:90", uri.host.?.percent_encoded); + try std.testing.expectEqualStrings("/home/test/", uri.path.percent_encoded); + try std.testing.expect(uri.port == 12); + + // Alphabetical mac addresses. + + uri = try parseUrl("file://ab:cd:ef:ab:cd:ef/home/test/"); + + try std.testing.expectEqualStrings("file", uri.scheme); + try std.testing.expectEqualStrings("ab:cd:ef:ab:cd:ef", uri.host.?.percent_encoded); + try std.testing.expectEqualStrings("/home/test/", uri.path.percent_encoded); + try std.testing.expect(uri.port == null); + + uri = try parseUrl("kitty-shell-cwd://ab:cd:ef:ab:cd:ef/home/test/"); + + try std.testing.expectEqualStrings("kitty-shell-cwd", uri.scheme); + try std.testing.expectEqualStrings("ab:cd:ef:ab:cd:ef", uri.host.?.percent_encoded); + try std.testing.expectEqualStrings("/home/test/", uri.path.percent_encoded); + try std.testing.expect(uri.port == null); + + // 3. Hostnames that are mac addresses with no path. + + // Numerical mac addresses. + + uri = try parseUrl("file://12:34:56:78:90:12"); + + try std.testing.expectEqualStrings("file", uri.scheme); + try std.testing.expectEqualStrings("12:34:56:78:90", uri.host.?.percent_encoded); + try std.testing.expectEqualStrings("", uri.path.percent_encoded); + try std.testing.expect(uri.port == 12); + + uri = try parseUrl("kitty-shell-cwd://12:34:56:78:90:12"); + + try std.testing.expectEqualStrings("kitty-shell-cwd", uri.scheme); + try std.testing.expectEqualStrings("12:34:56:78:90", uri.host.?.percent_encoded); + try std.testing.expectEqualStrings("", uri.path.percent_encoded); + try std.testing.expect(uri.port == 12); + + // Alphabetical mac addresses. + + uri = try parseUrl("file://ab:cd:ef:ab:cd:ef"); + + try std.testing.expectEqualStrings("file", uri.scheme); + try std.testing.expectEqualStrings("ab:cd:ef:ab:cd:ef", uri.host.?.percent_encoded); + try std.testing.expectEqualStrings("", uri.path.percent_encoded); + try std.testing.expect(uri.port == null); + + uri = try parseUrl("kitty-shell-cwd://ab:cd:ef:ab:cd:ef"); + + try std.testing.expectEqualStrings("kitty-shell-cwd", uri.scheme); + try std.testing.expectEqualStrings("ab:cd:ef:ab:cd:ef", uri.host.?.percent_encoded); + try std.testing.expectEqualStrings("", uri.path.percent_encoded); + try std.testing.expect(uri.port == null); +} + +test "parseUrl succeeds even if path component is missing" { + const uri = try parseUrl("file://12:34:56:78:90:ab"); + + try std.testing.expectEqualStrings("file", uri.scheme); + try std.testing.expectEqualStrings("12:34:56:78:90:ab", uri.host.?.percent_encoded); + try std.testing.expect(uri.path.isEmpty()); + try std.testing.expect(uri.port == null); +} + test "bufPrintHostnameFromFileUri succeeds with ascii hostname" { const uri = try std.Uri.parse("file://localhost/"); @@ -86,6 +272,14 @@ test "bufPrintHostnameFromFileUri succeeds with hostname as mac address" { try std.testing.expectEqualStrings("12:34:56:78:90:12", actual); } +test "bufPrintHostnameFromFileUri succeeds with hostname as mac address with the last component as ascii" { + const uri = try parseUrl("file://12:34:56:78:90:ab"); + + var buf: [posix.HOST_NAME_MAX]u8 = undefined; + const actual = try bufPrintHostnameFromFileUri(&buf, uri); + try std.testing.expectEqualStrings("12:34:56:78:90:ab", actual); +} + test "bufPrintHostnameFromFileUri succeeds with hostname as a mac address and the last section is < 10" { const uri = try std.Uri.parse("file://12:34:56:78:90:05"); diff --git a/src/os/i18n.zig b/src/os/i18n.zig index 7b8a6e6d7..82308d3ed 100644 --- a/src/os/i18n.zig +++ b/src/os/i18n.zig @@ -43,6 +43,7 @@ pub const locales = [_][:0]const u8{ "tr_TR.UTF-8", "id_ID.UTF-8", "es_BO.UTF-8", + "es_AR.UTF-8", "pt_BR.UTF-8", "ca_ES.UTF-8", "ga_IE.UTF-8", diff --git a/src/os/main.zig b/src/os/main.zig index 582ac75cd..906e3d150 100644 --- a/src/os/main.zig +++ b/src/os/main.zig @@ -29,6 +29,7 @@ pub const shell = @import("shell.zig"); // Functions and types pub const CFReleaseThread = @import("cf_release_thread.zig"); pub const TempDir = @import("TempDir.zig"); +pub const GetEnvResult = env.GetEnvResult; pub const getEnvMap = env.getEnvMap; pub const appendEnv = env.appendEnv; pub const appendEnvAlways = env.appendEnvAlways; @@ -55,6 +56,7 @@ pub const open = openpkg.open; pub const OpenType = openpkg.Type; pub const pipe = pipepkg.pipe; pub const resourcesDir = resourcesdir.resourcesDir; +pub const ResourcesDir = resourcesdir.ResourcesDir; pub const ShellEscapeWriter = shell.ShellEscapeWriter; test { diff --git a/src/os/open.zig b/src/os/open.zig index 39f28036f..ce62a7e0b 100644 --- a/src/os/open.zig +++ b/src/os/open.zig @@ -2,6 +2,8 @@ const std = @import("std"); const builtin = @import("builtin"); const Allocator = std.mem.Allocator; +const log = std.log.scoped(.@"os-open"); + /// The type of the data at the URL to open. This is used as a hint /// to potentially open the URL in a different way. pub const Type = enum { @@ -12,68 +14,73 @@ pub const Type = enum { /// Open a URL in the default handling application. /// /// Any output on stderr is logged as a warning in the application logs. -/// Output on stdout is ignored. +/// Output on stdout is ignored. The allocator is used to buffer the +/// log output and may allocate from another thread. pub fn open( alloc: Allocator, typ: Type, url: []const u8, ) !void { - const cmd: OpenCommand = switch (builtin.os.tag) { - .linux, .freebsd => .{ .child = std.process.Child.init( + var exe: std.process.Child = switch (builtin.os.tag) { + .linux, .freebsd => .init( &.{ "xdg-open", url }, alloc, - ) }, + ), - .windows => .{ .child = std.process.Child.init( + .windows => .init( &.{ "rundll32", "url.dll,FileProtocolHandler", url }, alloc, - ) }, + ), - .macos => .{ - .child = std.process.Child.init( - switch (typ) { - .text => &.{ "open", "-t", url }, - .unknown => &.{ "open", url }, - }, - alloc, - ), - .wait = true, - }, + .macos => .init( + switch (typ) { + .text => &.{ "open", "-t", url }, + .unknown => &.{ "open", url }, + }, + alloc, + ), .ios => return error.Unimplemented, else => @compileError("unsupported OS"), }; - var exe = cmd.child; - if (cmd.wait) { - // Pipe stdout/stderr so we can collect output from the command - exe.stdout_behavior = .Pipe; - exe.stderr_behavior = .Pipe; - } + // Pipe stdout/stderr so we can collect output from the command. + // This must be set before spawning the process. + exe.stdout_behavior = .Pipe; + exe.stderr_behavior = .Pipe; + // Spawn the process on our same thread so we can detect failure + // quickly. try exe.spawn(); - if (cmd.wait) { - // 50 KiB is the default value used by std.process.Child.run - const output_max_size = 50 * 1024; - - var stdout: std.ArrayListUnmanaged(u8) = .{}; - var stderr: std.ArrayListUnmanaged(u8) = .{}; - defer { - stdout.deinit(alloc); - stderr.deinit(alloc); - } - - try exe.collectOutput(alloc, &stdout, &stderr, output_max_size); - _ = try exe.wait(); - - // If we have any stderr output we log it. This makes it easier for - // users to debug why some open commands may not work as expected. - if (stderr.items.len > 0) std.log.err("open stderr={s}", .{stderr.items}); - } + // Create a thread that handles collecting output and reaping + // the process. This is done in a separate thread because SOME + // open implementations block and some do not. It's easier to just + // spawn a thread to handle this so that we never block. + const thread = try std.Thread.spawn(.{}, openThread, .{ alloc, exe }); + thread.detach(); } -const OpenCommand = struct { - child: std.process.Child, - wait: bool = false, -}; +fn openThread(alloc: Allocator, exe_: std.process.Child) !void { + // 50 KiB is the default value used by std.process.Child.run and should + // be enough to get the output we care about. + const output_max_size = 50 * 1024; + + var stdout: std.ArrayListUnmanaged(u8) = .{}; + var stderr: std.ArrayListUnmanaged(u8) = .{}; + defer { + stdout.deinit(alloc); + stderr.deinit(alloc); + } + + // Copy the exe so it is non-const. This is necessary because wait() + // requires a mutable reference and we can't have one as a thread + // param. + var exe = exe_; + try exe.collectOutput(alloc, &stdout, &stderr, output_max_size); + _ = try exe.wait(); + + // If we have any stderr output we log it. This makes it easier for + // users to debug why some open commands may not work as expected. + if (stderr.items.len > 0) log.warn("wait stderr={s}", .{stderr.items}); +} diff --git a/src/os/resourcesdir.zig b/src/os/resourcesdir.zig index 0ef92d3b3..278de44fc 100644 --- a/src/os/resourcesdir.zig +++ b/src/os/resourcesdir.zig @@ -2,13 +2,42 @@ const std = @import("std"); const builtin = @import("builtin"); const Allocator = std.mem.Allocator; +pub const ResourcesDir = struct { + /// Avoid accessing these directly, use the app() and host() methods instead. + app_path: ?[]const u8 = null, + host_path: ?[]const u8 = null, + + /// Free resources held. Requires the same allocator as when resourcesDir() + /// is called. + pub fn deinit(self: *ResourcesDir, alloc: Allocator) void { + if (self.app_path) |p| alloc.free(p); + if (self.host_path) |p| alloc.free(p); + } + + /// Get the directory to the bundled resources directory accessible + /// by the application. + pub fn app(self: *ResourcesDir) ?[]const u8 { + return self.app_path; + } + + /// Get the directory to the bundled resources directory accessible + /// by the host environment (i.e. for sandboxed applications). The + /// returned directory might not be accessible from the application + /// itself. + /// + /// In non-sandboxed environment, this should be the same as app(). + pub fn host(self: *ResourcesDir) ?[]const u8 { + return self.host_path orelse self.app_path; + } +}; + /// Gets the directory to the bundled resources directory, if it /// exists (not all platforms or packages have it). The output is /// owned by the caller. /// /// This is highly Ghostty-specific and can likely be generalized at /// some point but we can cross that bridge if we ever need to. -pub fn resourcesDir(alloc: std.mem.Allocator) !?[]const u8 { +pub fn resourcesDir(alloc: Allocator) !ResourcesDir { // Use the GHOSTTY_RESOURCES_DIR environment variable in release builds. // // In debug builds we try using terminfo detection first instead, since @@ -20,7 +49,7 @@ pub fn resourcesDir(alloc: std.mem.Allocator) !?[]const u8 { // freed, do not try to use internal_os.getenv or posix getenv. if (comptime builtin.mode != .Debug) { if (std.process.getEnvVarOwned(alloc, "GHOSTTY_RESOURCES_DIR")) |dir| { - if (dir.len > 0) return dir; + if (dir.len > 0) return .{ .app_path = dir }; } else |err| switch (err) { error.EnvironmentVariableNotFound => {}, else => return err, @@ -38,7 +67,7 @@ pub fn resourcesDir(alloc: std.mem.Allocator) !?[]const u8 { // Get the path to our running binary var exe_buf: [std.fs.max_path_bytes]u8 = undefined; - var exe: []const u8 = std.fs.selfExePath(&exe_buf) catch return null; + var exe: []const u8 = std.fs.selfExePath(&exe_buf) catch return .{}; // We have an exe path! Climb the tree looking for the terminfo // bundle as we expect it. @@ -50,7 +79,7 @@ pub fn resourcesDir(alloc: std.mem.Allocator) !?[]const u8 { if (comptime builtin.target.os.tag.isDarwin()) { inline for (sentinels) |sentinel| { if (try maybeDir(&dir_buf, dir, "Contents/Resources", sentinel)) |v| { - return try std.fs.path.join(alloc, &.{ v, "ghostty" }); + return .{ .app_path = try std.fs.path.join(alloc, &.{ v, "ghostty" }) }; } } } @@ -65,7 +94,7 @@ pub fn resourcesDir(alloc: std.mem.Allocator) !?[]const u8 { if (builtin.target.os.tag == .freebsd) "local/share" else "share", sentinel, )) |v| { - return try std.fs.path.join(alloc, &.{ v, "ghostty" }); + return .{ .app_path = try std.fs.path.join(alloc, &.{ v, "ghostty" }) }; } } } @@ -74,14 +103,14 @@ pub fn resourcesDir(alloc: std.mem.Allocator) !?[]const u8 { // fallback and use the provided resources dir. if (comptime builtin.mode == .Debug) { if (std.process.getEnvVarOwned(alloc, "GHOSTTY_RESOURCES_DIR")) |dir| { - if (dir.len > 0) return dir; + if (dir.len > 0) return .{ .app_path = dir }; } else |err| switch (err) { error.EnvironmentVariableNotFound => {}, else => return err, } } - return null; + return .{}; } /// Little helper to check if the "base/sub/suffix" directory exists and diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index a001ca08d..3899bb8c5 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -29,9 +29,6 @@ pub const Buffer = bufferpkg.Buffer; pub const Texture = @import("metal/Texture.zig"); pub const shaders = @import("metal/shaders.zig"); -pub const cellpkg = @import("metal/cell.zig"); -pub const imagepkg = @import("metal/image.zig"); - pub const custom_shader_target: shadertoy.Target = .msl; // The fragCoord for Metal shaders is +Y = down. pub const custom_shader_y_is_down = true; @@ -285,6 +282,7 @@ pub const uniformBufferOptions = bufferOptions; pub const fgBufferOptions = bufferOptions; pub const bgBufferOptions = bufferOptions; pub const imageBufferOptions = bufferOptions; +pub const bgImageBufferOptions = bufferOptions; /// Returns the options to use when constructing textures. pub inline fn textureOptions(self: Metal) Texture.Options { @@ -305,6 +303,44 @@ pub inline fn textureOptions(self: Metal) Texture.Options { }; } +/// Pixel format for image texture options. +pub const ImageTextureFormat = enum { + /// 1 byte per pixel grayscale. + gray, + /// 4 bytes per pixel RGBA. + rgba, + /// 4 bytes per pixel BGRA. + bgra, + + fn toPixelFormat( + self: ImageTextureFormat, + srgb: bool, + ) mtl.MTLPixelFormat { + return switch (self) { + .gray => if (srgb) .r8unorm_srgb else .r8unorm, + .rgba => if (srgb) .rgba8unorm_srgb else .rgba8unorm, + .bgra => if (srgb) .bgra8unorm_srgb else .bgra8unorm, + }; + } +}; + +/// Returns the options to use when constructing textures for images. +pub inline fn imageTextureOptions( + self: Metal, + format: ImageTextureFormat, + srgb: bool, +) Texture.Options { + return .{ + .device = self.device, + .pixel_format = format.toPixelFormat(srgb), + .resource_options = .{ + // Indicate that the CPU writes to this resource but never reads it. + .cpu_cache_mode = .write_combined, + .storage_mode = self.default_storage_mode, + }, + }; +} + /// Initializes a Texture suitable for the provided font atlas. pub fn initAtlasTexture( self: *const Metal, @@ -312,7 +348,7 @@ pub fn initAtlasTexture( ) Texture.Error!Texture { const pixel_format: mtl.MTLPixelFormat = switch (atlas.format) { .grayscale => .r8unorm, - .rgba => .bgra8unorm, + .bgra => .bgra8unorm_srgb, else => @panic("unsupported atlas format for Metal texture"), }; diff --git a/src/renderer/OpenGL.zig b/src/renderer/OpenGL.zig index dcc295eaf..cf195361e 100644 --- a/src/renderer/OpenGL.zig +++ b/src/renderer/OpenGL.zig @@ -24,9 +24,6 @@ pub const Buffer = bufferpkg.Buffer; pub const Texture = @import("opengl/Texture.zig"); pub const shaders = @import("opengl/shaders.zig"); -pub const cellpkg = @import("opengl/cell.zig"); -pub const imagepkg = @import("opengl/image.zig"); - pub const custom_shader_target: shadertoy.Target = .glsl; // The fragCoord for OpenGL shaders is +Y = up. pub const custom_shader_y_is_down = false; @@ -391,6 +388,7 @@ pub const uniformBufferOptions = bufferOptions; pub const fgBufferOptions = bufferOptions; pub const bgBufferOptions = bufferOptions; pub const imageBufferOptions = bufferOptions; +pub const bgImageBufferOptions = bufferOptions; /// Returns the options to use when constructing textures. pub inline fn textureOptions(self: OpenGL) Texture.Options { @@ -402,6 +400,38 @@ pub inline fn textureOptions(self: OpenGL) Texture.Options { }; } +/// Pixel format for image texture options. +pub const ImageTextureFormat = enum { + /// 1 byte per pixel grayscale. + gray, + /// 4 bytes per pixel RGBA. + rgba, + /// 4 bytes per pixel BGRA. + bgra, + + fn toPixelFormat(self: ImageTextureFormat) gl.Texture.Format { + return switch (self) { + .gray => .red, + .rgba => .rgba, + .bgra => .bgra, + }; + } +}; + +/// Returns the options to use when constructing textures for images. +pub inline fn imageTextureOptions( + self: OpenGL, + format: ImageTextureFormat, + srgb: bool, +) Texture.Options { + _ = self; + return .{ + .format = format.toPixelFormat(), + .internal_format = if (srgb) .srgba else .rgba, + .target = .@"2D", + }; +} + /// Initializes a Texture suitable for the provided font atlas. pub fn initAtlasTexture( self: *const OpenGL, @@ -411,7 +441,7 @@ pub fn initAtlasTexture( const format: gl.Texture.Format, const internal_format: gl.Texture.InternalFormat = switch (atlas.format) { .grayscale => .{ .red, .red }, - .rgba => .{ .rgba, .srgba }, + .bgra => .{ .bgra, .srgba }, else => @panic("unsupported atlas format for OpenGL texture"), }; diff --git a/src/renderer/cell.zig b/src/renderer/cell.zig index c84fbcc6f..ef7122699 100644 --- a/src/renderer/cell.zig +++ b/src/renderer/cell.zig @@ -1,6 +1,197 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const assert = std.debug.assert; const ziglyph = @import("ziglyph"); const font = @import("../font/main.zig"); const terminal = @import("../terminal/main.zig"); +const renderer = @import("../renderer.zig"); +const shaderpkg = renderer.Renderer.API.shaders; +const ArrayListCollection = @import("../datastruct/array_list_collection.zig").ArrayListCollection; + +/// The possible cell content keys that exist. +pub const Key = enum { + bg, + text, + underline, + strikethrough, + overline, + + /// Returns the GPU vertex type for this key. + pub fn CellType(self: Key) type { + return switch (self) { + .bg => shaderpkg.CellBg, + + .text, + .underline, + .strikethrough, + .overline, + => shaderpkg.CellText, + }; + } +}; + +/// The contents of all the cells in the terminal. +/// +/// The goal of this data structure is to allow for efficient row-wise +/// clearing of data from the GPU buffers, to allow for row-wise dirty +/// tracking to eliminate the overhead of rebuilding the GPU buffers +/// each frame. +/// +/// Must be initialized by resizing before calling any operations. +pub const Contents = struct { + size: renderer.GridSize = .{ .rows = 0, .columns = 0 }, + + /// Flat array containing cell background colors for the terminal grid. + /// + /// Indexed as `bg_cells[row * size.columns + col]`. + /// + /// Prefer accessing with `Contents.bgCell(row, col).*` instead + /// of directly indexing in order to avoid integer size bugs. + bg_cells: []shaderpkg.CellBg = undefined, + + /// The ArrayListCollection which holds all of the foreground cells. When + /// sized with Contents.resize the individual ArrayLists are given enough + /// room that they can hold a single row with #cols glyphs, underlines, and + /// strikethroughs; however, appendAssumeCapacity MUST NOT be used since + /// it is possible to exceed this with combining glyphs that add a glyph + /// but take up no column since they combine with the previous one, as + /// well as with fonts that perform multi-substitutions for glyphs, which + /// can result in a similar situation where multiple glyphs reside in the + /// same column. + /// + /// Allocations should nevertheless be exceedingly rare since hitting the + /// initial capacity of a list would require a row filled with underlined + /// struck through characters, at least one of which is a multi-glyph + /// composite. + /// + /// Rows are indexed as Contents.fg_rows[y + 1], because the first list in + /// the collection is reserved for the cursor, which must be the first item + /// in the buffer. + /// + /// Must be initialized by calling resize on the Contents struct before + /// calling any operations. + fg_rows: ArrayListCollection(shaderpkg.CellText) = .{ .lists = &.{} }, + + pub fn deinit(self: *Contents, alloc: Allocator) void { + alloc.free(self.bg_cells); + self.fg_rows.deinit(alloc); + } + + /// Resize the cell contents for the given grid size. This will + /// always invalidate the entire cell contents. + pub fn resize( + self: *Contents, + alloc: Allocator, + size: renderer.GridSize, + ) Allocator.Error!void { + self.size = size; + + const cell_count = @as(usize, size.columns) * @as(usize, size.rows); + + const bg_cells = try alloc.alloc(shaderpkg.CellBg, cell_count); + errdefer alloc.free(bg_cells); + + @memset(bg_cells, .{ 0, 0, 0, 0 }); + + // The foreground lists can hold 3 types of items: + // - Glyphs + // - Underlines + // - Strikethroughs + // So we give them an initial capacity of size.columns * 3, which will + // avoid any further allocations in the vast majority of cases. Sadly + // we can not assume capacity though, since with combining glyphs that + // form a single grapheme, and multi-substitutions in fonts, the number + // of glyphs in a row is theoretically unlimited. + // + // We have size.rows + 1 lists because index 0 is used for a special + // list containing the cursor cell which needs to be first in the buffer. + var fg_rows = try ArrayListCollection(shaderpkg.CellText).init( + alloc, + size.rows + 1, + size.columns * 3, + ); + errdefer fg_rows.deinit(alloc); + + alloc.free(self.bg_cells); + self.fg_rows.deinit(alloc); + + self.bg_cells = bg_cells; + self.fg_rows = fg_rows; + + // We don't need 3*cols worth of cells for the cursor list, so we can + // replace it with a smaller list. This is technically a tiny bit of + // extra work but resize is not a hot function so it's worth it to not + // waste the memory. + self.fg_rows.lists[0].deinit(alloc); + self.fg_rows.lists[0] = try std.ArrayListUnmanaged( + shaderpkg.CellText, + ).initCapacity(alloc, 1); + } + + /// Reset the cell contents to an empty state without resizing. + pub fn reset(self: *Contents) void { + @memset(self.bg_cells, .{ 0, 0, 0, 0 }); + self.fg_rows.reset(); + } + + /// Set the cursor value. If the value is null then the cursor is hidden. + pub fn setCursor(self: *Contents, v: ?shaderpkg.CellText) void { + self.fg_rows.lists[0].clearRetainingCapacity(); + + if (v) |cell| { + self.fg_rows.lists[0].appendAssumeCapacity(cell); + } + } + + /// Access a background cell. Prefer this function over direct indexing + /// of `bg_cells` in order to avoid integer size bugs causing overflows. + pub inline fn bgCell( + self: *Contents, + row: usize, + col: usize, + ) *shaderpkg.CellBg { + return &self.bg_cells[row * self.size.columns + col]; + } + + /// Add a cell to the appropriate list. Adding the same cell twice will + /// result in duplication in the vertex buffer. The caller should clear + /// the corresponding row with Contents.clear to remove old cells first. + pub fn add( + self: *Contents, + alloc: Allocator, + comptime key: Key, + cell: key.CellType(), + ) Allocator.Error!void { + const y = cell.grid_pos[1]; + + assert(y < self.size.rows); + + switch (key) { + .bg => comptime unreachable, + + .text, + .underline, + .strikethrough, + .overline, + // We have a special list containing the cursor cell at the start + // of our fg row collection, so we need to add 1 to the y to get + // the correct index. + => try self.fg_rows.lists[y + 1].append(alloc, cell), + } + } + + /// Clear all of the cell contents for a given row. + pub fn clear(self: *Contents, y: terminal.size.CellCountInt) void { + assert(y < self.size.rows); + + @memset(self.bg_cells[@as(usize, y) * self.size.columns ..][0..self.size.columns], .{ 0, 0, 0, 0 }); + + // We have a special list containing the cursor cell at the start + // of our fg row collection, so we need to add 1 to the y to get + // the correct index. + self.fg_rows.lists[y + 1].clearRetainingCapacity(); + } +}; /// Returns true if a codepoint for a cell is a covering character. A covering /// character is a character that covers the entire cell. This is used to @@ -38,7 +229,7 @@ pub const FgMode = enum { pub fn fgMode( presentation: font.Presentation, cell_pin: terminal.Pin, -) !FgMode { +) FgMode { return switch (presentation) { // Emoji is always full size and color. .emoji => .color, @@ -131,3 +322,141 @@ fn isPowerline(char: u21) bool { else => false, }; } + +test Contents { + const testing = std.testing; + const alloc = testing.allocator; + + const rows = 10; + const cols = 10; + + var c: Contents = .{}; + try c.resize(alloc, .{ .rows = rows, .columns = cols }); + defer c.deinit(alloc); + + // We should start off empty after resizing. + for (0..rows) |y| { + try testing.expect(c.fg_rows.lists[y + 1].items.len == 0); + for (0..cols) |x| { + try testing.expectEqual(.{ 0, 0, 0, 0 }, c.bgCell(y, x).*); + } + } + // And the cursor row should have a capacity of 1 and also be empty. + try testing.expect(c.fg_rows.lists[0].capacity == 1); + try testing.expect(c.fg_rows.lists[0].items.len == 0); + + // Add some contents. + const bg_cell: shaderpkg.CellBg = .{ 0, 0, 0, 1 }; + const fg_cell: shaderpkg.CellText = .{ + .mode = .fg, + .grid_pos = .{ 4, 1 }, + .color = .{ 0, 0, 0, 1 }, + }; + c.bgCell(1, 4).* = bg_cell; + try c.add(alloc, .text, fg_cell); + try testing.expectEqual(bg_cell, c.bgCell(1, 4).*); + // The fg row index is offset by 1 because of the cursor list. + try testing.expectEqual(fg_cell, c.fg_rows.lists[2].items[0]); + + // And we should be able to clear it. + c.clear(1); + for (0..rows) |y| { + try testing.expect(c.fg_rows.lists[y + 1].items.len == 0); + for (0..cols) |x| { + try testing.expectEqual(.{ 0, 0, 0, 0 }, c.bgCell(y, x).*); + } + } + + // Add a cursor. + const cursor_cell: shaderpkg.CellText = .{ + .mode = .cursor, + .grid_pos = .{ 2, 3 }, + .color = .{ 0, 0, 0, 1 }, + }; + c.setCursor(cursor_cell); + try testing.expectEqual(cursor_cell, c.fg_rows.lists[0].items[0]); + + // And remove it. + c.setCursor(null); + try testing.expectEqual(0, c.fg_rows.lists[0].items.len); +} + +test "Contents clear retains other content" { + const testing = std.testing; + const alloc = testing.allocator; + + const rows = 10; + const cols = 10; + + var c: Contents = .{}; + try c.resize(alloc, .{ .rows = rows, .columns = cols }); + defer c.deinit(alloc); + + // Set some contents + // bg and fg cells in row 1 + const bg_cell_1: shaderpkg.CellBg = .{ 0, 0, 0, 1 }; + const fg_cell_1: shaderpkg.CellText = .{ + .mode = .fg, + .grid_pos = .{ 4, 1 }, + .color = .{ 0, 0, 0, 1 }, + }; + c.bgCell(1, 4).* = bg_cell_1; + try c.add(alloc, .text, fg_cell_1); + // bg and fg cells in row 2 + const bg_cell_2: shaderpkg.CellBg = .{ 0, 0, 0, 1 }; + const fg_cell_2: shaderpkg.CellText = .{ + .mode = .fg, + .grid_pos = .{ 4, 2 }, + .color = .{ 0, 0, 0, 1 }, + }; + c.bgCell(2, 4).* = bg_cell_2; + try c.add(alloc, .text, fg_cell_2); + + // Clear row 1, this should leave row 2 untouched + c.clear(1); + + // Row 2 should still contain its cells. + try testing.expectEqual(bg_cell_2, c.bgCell(2, 4).*); + // Fg row index is +1 because of cursor list at start + try testing.expectEqual(fg_cell_2, c.fg_rows.lists[3].items[0]); +} + +test "Contents clear last added content" { + const testing = std.testing; + const alloc = testing.allocator; + + const rows = 10; + const cols = 10; + + var c: Contents = .{}; + try c.resize(alloc, .{ .rows = rows, .columns = cols }); + defer c.deinit(alloc); + + // Set some contents + // bg and fg cells in row 1 + const bg_cell_1: shaderpkg.CellBg = .{ 0, 0, 0, 1 }; + const fg_cell_1: shaderpkg.CellText = .{ + .mode = .fg, + .grid_pos = .{ 4, 1 }, + .color = .{ 0, 0, 0, 1 }, + }; + c.bgCell(1, 4).* = bg_cell_1; + try c.add(alloc, .text, fg_cell_1); + // bg and fg cells in row 2 + const bg_cell_2: shaderpkg.CellBg = .{ 0, 0, 0, 1 }; + const fg_cell_2: shaderpkg.CellText = .{ + .mode = .fg, + .grid_pos = .{ 4, 2 }, + .color = .{ 0, 0, 0, 1 }, + }; + c.bgCell(2, 4).* = bg_cell_2; + try c.add(alloc, .text, fg_cell_2); + + // Clear row 2, this should leave row 1 untouched + c.clear(2); + + // Row 1 should still contain its cells. + try testing.expectEqual(bg_cell_1, c.bgCell(1, 4).*); + // Fg row index is +1 because of cursor list at start + try testing.expectEqual(fg_cell_1, c.fg_rows.lists[2].items[0]); +} diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index c0091cbf6..fba577231 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -2,6 +2,7 @@ const std = @import("std"); const builtin = @import("builtin"); const glfw = @import("glfw"); const xev = @import("xev"); +const wuffs = @import("wuffs"); const apprt = @import("../apprt.zig"); const configpkg = @import("../config.zig"); const font = @import("../font/main.zig"); @@ -11,8 +12,13 @@ const renderer = @import("../renderer.zig"); const math = @import("../math.zig"); const Surface = @import("../Surface.zig"); const link = @import("link.zig"); -const fgMode = @import("cell.zig").fgMode; -const isCovering = @import("cell.zig").isCovering; +const cellpkg = @import("cell.zig"); +const fgMode = cellpkg.fgMode; +const isCovering = cellpkg.isCovering; +const imagepkg = @import("image.zig"); +const Image = imagepkg.Image; +const ImageMap = imagepkg.ImageMap; +const ImagePlacementList = std.ArrayListUnmanaged(imagepkg.Placement); const shadertoy = @import("shadertoy.zig"); const assert = std.debug.assert; const Allocator = std.mem.Allocator; @@ -20,6 +26,8 @@ const ArenaAllocator = std.heap.ArenaAllocator; const Terminal = terminal.Terminal; const Health = renderer.Health; +const FileType = @import("../file_type.zig").FileType; + const macos = switch (builtin.os.tag) { .macos => @import("macos"), else => void, @@ -71,21 +79,16 @@ pub fn Renderer(comptime GraphicsAPI: type) type { return struct { const Self = @This(); + pub const API = GraphicsAPI; + const Target = GraphicsAPI.Target; const Buffer = GraphicsAPI.Buffer; const Texture = GraphicsAPI.Texture; const RenderPass = GraphicsAPI.RenderPass; + const shaderpkg = GraphicsAPI.shaders; - - const cellpkg = GraphicsAPI.cellpkg; - const imagepkg = GraphicsAPI.imagepkg; - const Image = imagepkg.Image; - const ImageMap = imagepkg.ImageMap; - const Shaders = shaderpkg.Shaders; - const ImagePlacementList = std.ArrayListUnmanaged(imagepkg.Placement); - /// Allocator that can be used alloc: std.mem.Allocator, @@ -181,6 +184,21 @@ pub fn Renderer(comptime GraphicsAPI: type) type { image_text_end: u32 = 0, image_virtual: bool = false, + /// Background image, if we have one. + bg_image: ?imagepkg.Image = null, + /// Set whenever the background image changes, singalling + /// that the new background image needs to be uploaded to + /// the GPU. + /// + /// This is initialized as true so that we load the image + /// on renderer initialization, not just on config change. + bg_image_changed: bool = true, + /// Background image vertex buffer. + bg_image_buffer: shaderpkg.BgImage, + /// This value is used to force-update the swap chain copy + /// of the background image buffer whenever we change it. + bg_image_buffer_modified: usize = 0, + /// Graphics API state. api: GraphicsAPI, @@ -298,13 +316,21 @@ pub fn Renderer(comptime GraphicsAPI: type) type { /// See property of same name on Renderer for explanation. target_config_modified: usize = 0, + /// Buffer with the vertex data for our background image. + /// + /// TODO: Make this an optional and only create it + /// if we actually have a background image. + bg_image_buffer: BgImageBuffer, + /// See property of same name on Renderer for explanation. + bg_image_buffer_modified: usize = 0, + /// Custom shader state, this is null if we have no custom shaders. custom_shader_state: ?CustomShaderState = null, - /// A buffer containing the uniform data. const UniformBuffer = Buffer(shaderpkg.Uniforms); const CellBgBuffer = Buffer(shaderpkg.CellBg); const CellTextBuffer = Buffer(shaderpkg.CellText); + const BgImageBuffer = Buffer(shaderpkg.BgImage); pub fn init(api: GraphicsAPI, custom_shaders: bool) !FrameState { // Uniform buffer contains exactly 1 uniform struct. The @@ -324,6 +350,13 @@ pub fn Renderer(comptime GraphicsAPI: type) type { var cells_bg = try CellBgBuffer.init(api.bgBufferOptions(), 1); errdefer cells_bg.deinit(); + // Create a GPU buffer for our background image info. + var bg_image_buffer = try BgImageBuffer.init( + api.bgImageBufferOptions(), + 1, + ); + errdefer bg_image_buffer.deinit(); + // Initialize our textures for our font atlas. // // As with the buffers above, we start these off as small @@ -337,7 +370,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { const color = try api.initAtlasTexture(&.{ .data = undefined, .size = 1, - .format = .rgba, + .format = .bgra, }); errdefer color.deinit(); @@ -356,6 +389,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { .uniforms = uniforms, .cells = cells, .cells_bg = cells_bg, + .bg_image_buffer = bg_image_buffer, .grayscale = grayscale, .color = color, .target = target, @@ -369,6 +403,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self.cells_bg.deinit(); self.grayscale.deinit(); self.color.deinit(); + self.bg_image_buffer.deinit(); if (self.custom_shader_state) |*state| state.deinit(); } @@ -395,12 +430,20 @@ pub fn Renderer(comptime GraphicsAPI: type) type { front_texture: Texture, back_texture: Texture, + uniforms: UniformBuffer, + + const UniformBuffer = Buffer(shadertoy.Uniforms); + /// Swap the front and back textures. pub fn swap(self: *CustomShaderState) void { std.mem.swap(Texture, &self.front_texture, &self.back_texture); } pub fn init(api: GraphicsAPI) !CustomShaderState { + // Create a GPU buffer to hold our uniforms. + var uniforms = try UniformBuffer.init(api.uniformBufferOptions(), 1); + errdefer uniforms.deinit(); + // Initialize the front and back textures at 1x1 px, this // is slightly wasteful but it's only done once so whatever. const front_texture = try Texture.init( @@ -417,15 +460,18 @@ pub fn Renderer(comptime GraphicsAPI: type) type { null, ); errdefer back_texture.deinit(); + return .{ .front_texture = front_texture, .back_texture = back_texture, + .uniforms = uniforms, }; } pub fn deinit(self: *CustomShaderState) void { self.front_texture.deinit(); self.back_texture.deinit(); + self.uniforms.deinit(); } pub fn resize( @@ -467,6 +513,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { font_thicken_strength: u8, font_features: std.ArrayListUnmanaged([:0]const u8), font_styles: font.CodepointResolver.StyleStatus, + font_shaping_break: configpkg.FontShapingBreak, cursor_color: ?terminal.color.RGB, cursor_invert: bool, cursor_opacity: f64, @@ -481,6 +528,11 @@ pub fn Renderer(comptime GraphicsAPI: type) type { min_contrast: f32, padding_color: configpkg.WindowPaddingColor, custom_shaders: configpkg.RepeatablePath, + bg_image: ?configpkg.Path, + bg_image_opacity: f32, + bg_image_position: configpkg.BackgroundImagePosition, + bg_image_fit: configpkg.BackgroundImageFit, + bg_image_repeat: bool, links: link.Set, vsync: bool, colorspace: configpkg.Config.WindowColorspace, @@ -497,6 +549,13 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Copy our shaders const custom_shaders = try config.@"custom-shader".clone(alloc); + // Copy our background image + const bg_image = + if (config.@"background-image") |bg| + try bg.clone(alloc) + else + null; + // Copy our font features const font_features = try config.@"font-feature".clone(alloc); @@ -520,6 +579,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { .font_thicken_strength = config.@"font-thicken-strength", .font_features = font_features.list, .font_styles = font_styles, + .font_shaping_break = config.@"font-shaping-break", .cursor_color = if (!cursor_invert and config.@"cursor-color" != null) config.@"cursor-color".?.toTerminalRGB() @@ -553,6 +613,11 @@ pub fn Renderer(comptime GraphicsAPI: type) type { null, .custom_shaders = custom_shaders, + .bg_image = bg_image, + .bg_image_opacity = config.@"background-image-opacity", + .bg_image_position = config.@"background-image-position", + .bg_image_fit = config.@"background-image-fit", + .bg_image_repeat = config.@"background-image-repeat", .links = links, .vsync = config.@"window-vsync", .colorspace = config.@"window-colorspace", @@ -647,6 +712,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { .cell_size = undefined, .grid_size = undefined, .grid_padding = undefined, + .screen_size = undefined, .padding_extend = .{}, .min_contrast = options.config.min_contrast, .cursor_pos = .{ std.math.maxInt(u16), std.math.maxInt(u16) }, @@ -670,7 +736,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { .time_delta = 0, .frame_rate = 60, // not currently updated .frame = 0, - .channel_time = @splat(@splat(0)), + .channel_time = @splat(@splat(0)), // not currently updated .channel_resolution = @splat(@splat(0)), .mouse = @splat(0), // not currently updated .date = @splat(0), // not currently updated @@ -681,6 +747,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { .previous_cursor_color = @splat(0), .cursor_change_time = 0, }, + .bg_image_buffer = undefined, // Fonts .font_grid = options.font_grid, @@ -701,6 +768,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Ensure our undefined values above are correctly initialized. result.updateFontGridUniforms(); result.updateScreenSizeUniforms(); + result.updateBgImageBuffer(); + try result.prepBackgroundImage(); return result; } @@ -729,6 +798,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type { } self.image_placements.deinit(self.alloc); + if (self.bg_image) |img| img.deinit(self.alloc); + self.deinitShaders(); self.api.deinit(); @@ -1324,32 +1395,10 @@ pub fn Renderer(comptime GraphicsAPI: type) type { } // Upload images to the GPU as necessary. - { - var image_it = self.images.iterator(); - while (image_it.next()) |kv| { - switch (kv.value_ptr.image) { - .ready => {}, + try self.uploadKittyImages(); - .pending_gray, - .pending_gray_alpha, - .pending_rgb, - .pending_rgba, - .replace_gray, - .replace_gray_alpha, - .replace_rgb, - .replace_rgba, - => try kv.value_ptr.image.upload(self.alloc, &self.api), - - .unload_pending, - .unload_replace, - .unload_ready, - => { - kv.value_ptr.image.deinit(self.alloc); - self.images.removeByPtr(kv.key_ptr); - }, - } - } - } + // Upload the background image to the GPU as necessary. + try self.uploadBackgroundImage(); // Update custom shader uniforms if necessary. try self.updateCustomShaderUniforms(); @@ -1359,6 +1408,13 @@ pub fn Renderer(comptime GraphicsAPI: type) type { try frame.cells_bg.sync(self.cells.bg_cells); const fg_count = try frame.cells.syncFromArrayLists(self.cells.fg_rows.lists); + // If our background image buffer has changed, sync it. + if (frame.bg_image_buffer_modified != self.bg_image_buffer_modified) { + try frame.bg_image_buffer.sync(&.{self.bg_image_buffer}); + + frame.bg_image_buffer_modified = self.bg_image_buffer_modified; + } + // If our font atlas changed, sync the texture data texture: { const modified = self.font_grid.atlas_grayscale.modified.load(.monotonic); @@ -1391,23 +1447,58 @@ pub fn Renderer(comptime GraphicsAPI: type) type { }}); defer pass.complete(); - // bg images - try self.drawImagePlacements(&pass, self.image_placements.items[0..self.image_bg_end]); - // bg + // First we draw our background image, if we have one. + // The bg image shader also draws the main bg color. + // + // Otherwise, if we don't have a background image, we + // draw the background color by itself in its own step. + // + // NOTE: We don't use the clear_color for this because that + // would require us to do color space conversion on the + // CPU-side. In the future when we have utilities for + // that we should remove this step and use clear_color. + if (self.bg_image) |img| switch (img) { + .ready => |texture| pass.step(.{ + .pipeline = self.shaders.pipelines.bg_image, + .uniforms = frame.uniforms.buffer, + .buffers = &.{frame.bg_image_buffer.buffer}, + .textures = &.{texture}, + .draw = .{ .type = .triangle, .vertex_count = 3 }, + }), + else => {}, + } else { + pass.step(.{ + .pipeline = self.shaders.pipelines.bg_color, + .uniforms = frame.uniforms.buffer, + .buffers = &.{ null, frame.cells_bg.buffer }, + .draw = .{ .type = .triangle, .vertex_count = 3 }, + }); + } + + // Then we draw any kitty images that need + // to be behind text AND cell backgrounds. + try self.drawImagePlacements( + &pass, + self.image_placements.items[0..self.image_bg_end], + ); + + // Then we draw any opaque cell backgrounds. pass.step(.{ - .pipeline = self.shaders.cell_bg_pipeline, + .pipeline = self.shaders.pipelines.cell_bg, .uniforms = frame.uniforms.buffer, .buffers = &.{ null, frame.cells_bg.buffer }, - .draw = .{ - .type = .triangle, - .vertex_count = 3, - }, + .draw = .{ .type = .triangle, .vertex_count = 3 }, }); - // mg images - try self.drawImagePlacements(&pass, self.image_placements.items[self.image_bg_end..self.image_text_end]); - // text + + // Kitty images between cell backgrounds and text. + try self.drawImagePlacements( + &pass, + self.image_placements.items[self.image_bg_end..self.image_text_end], + ); + + // Text. pass.step(.{ - .pipeline = self.shaders.cell_text_pipeline, + .pipeline = self.shaders.pipelines.cell_text, .uniforms = frame.uniforms.buffer, .buffers = &.{ frame.cells.buffer, @@ -1423,20 +1514,18 @@ pub fn Renderer(comptime GraphicsAPI: type) type { .instance_count = fg_count, }, }); - // fg images - try self.drawImagePlacements(&pass, self.image_placements.items[self.image_text_end..]); + + // Kitty images in front of text. + try self.drawImagePlacements( + &pass, + self.image_placements.items[self.image_text_end..], + ); } // If we have custom shaders, then we render them. if (frame.custom_shader_state) |*state| { - // We create a buffer on the GPU for our post uniforms. - // TODO: This should be a part of the frame state tbqh. - const PostBuffer = Buffer(shadertoy.Uniforms); - const uniform_buffer = try PostBuffer.initFill( - self.api.bufferOptions(), - &.{self.custom_shader_uniforms}, - ); - defer uniform_buffer.deinit(); + // Sync our uniforms. + try state.uniforms.sync(&.{self.custom_shader_uniforms}); for (self.shaders.post_pipelines, 0..) |pipeline, i| { defer state.swap(); @@ -1452,7 +1541,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { pass.step(.{ .pipeline = pipeline, - .uniforms = uniform_buffer.buffer, + .uniforms = state.uniforms.buffer, .textures = &.{state.back_texture}, .draw = .{ .type = .triangle, @@ -1539,7 +1628,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { defer buf.deinit(); pass.step(.{ - .pipeline = self.shaders.image_pipeline, + .pipeline = self.shaders.pipelines.image, .buffers = &.{buf.buffer}, .textures = &.{texture}, .draw = .{ @@ -1551,8 +1640,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { } /// This goes through the Kitty graphic placements and accumulates the - /// placements we need to render on our viewport. It also ensures that - /// the visible images are loaded on the GPU. + /// placements we need to render on our viewport. fn prepKittyGraphics( self: *Self, t: *terminal.Terminal, @@ -1589,7 +1677,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type { const top_y = t.screen.pages.pointFromPin(.screen, top).?.screen.y; const bot_y = t.screen.pages.pointFromPin(.screen, bot).?.screen.y; - // Go through the placements and ensure the image is loaded on the GPU. + // Go through the placements and ensure the image is + // on the GPU or else is ready to be sent to the GPU. var it = storage.placements.iterator(); while (it.next()) |kv| { const p = kv.value_ptr; @@ -1648,8 +1737,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type { }.lessThan, ); - // Find our indices. The values are sorted by z so we can find the - // first placement out of bounds to find the limits. + // Find our indices. The values are sorted by z so we can + // find the first placement out of bounds to find the limits. var bg_end: ?u32 = null; var text_end: ?u32 = null; const bg_limit = std.math.minInt(i32) / 2; @@ -1662,8 +1751,14 @@ pub fn Renderer(comptime GraphicsAPI: type) type { } } - self.image_bg_end = bg_end orelse 0; - self.image_text_end = text_end orelse self.image_bg_end; + // If we didn't see any images with a z > the bg limit, + // then our bg end is the end of our placement list. + self.image_bg_end = + bg_end orelse @intCast(self.image_placements.items.len); + + // Same idea for the image_text_end. + self.image_text_end = + text_end orelse @intCast(self.image_placements.items.len); } fn prepKittyVirtualPlacement( @@ -1704,7 +1799,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { unreachable; }; - // Send our image to the GPU and store the placement for rendering. + // Prepare the image for the GPU and store the placement. try self.prepKittyImage(&image); try self.image_placements.append(self.alloc, .{ .image_id = image.id, @@ -1722,6 +1817,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type { }); } + /// Get the viewport-relative position for this + /// placement and add it to the placements list. fn prepKittyPlacement( self: *Self, t: *terminal.Terminal, @@ -1742,9 +1839,9 @@ pub fn Renderer(comptime GraphicsAPI: type) type { if (img_top_y > bot_y) return; if (img_bot_y < top_y) return; - // We need to prep this image for upload if it isn't in the cache OR - // it is in the cache but the transmit time doesn't match meaning this - // image is different. + // We need to prep this image for upload if it isn't in the + // cache OR it is in the cache but the transmit time doesn't + // match meaning this image is different. try self.prepKittyImage(image); // Calculate the dimensions of our image, taking in to @@ -1785,6 +1882,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type { } } + /// Prepare the provided image for upload to the GPU by copying its + /// data with our allocator and setting it to the pending state. fn prepKittyImage( self: *Self, image: *const terminal.kitty.graphics.Image, @@ -1806,16 +1905,17 @@ pub fn Renderer(comptime GraphicsAPI: type) type { const pending: Image.Pending = .{ .width = image.width, .height = image.height, + .pixel_format = switch (image.format) { + .gray => .gray, + .gray_alpha => .gray_alpha, + .rgb => .rgb, + .rgba => .rgba, + .png => unreachable, // should be decoded by now + }, .data = data.ptr, }; - const new_image: Image = switch (image.format) { - .gray => .{ .pending_gray = pending }, - .gray_alpha => .{ .pending_gray_alpha = pending }, - .rgb => .{ .pending_rgb = pending }, - .rgba => .{ .pending_rgba = pending }, - .png => unreachable, // should be decoded by now - }; + const new_image: Image = .{ .pending = pending }; if (!gop.found_existing) { gop.value_ptr.* = .{ @@ -1829,9 +1929,122 @@ pub fn Renderer(comptime GraphicsAPI: type) type { ); } + try gop.value_ptr.image.prepForUpload(self.alloc); + gop.value_ptr.transmit_time = image.transmit_time; } + /// Upload any images to the GPU that need to be uploaded, + /// and remove any images that are no longer needed on the GPU. + fn uploadKittyImages(self: *Self) !void { + var image_it = self.images.iterator(); + while (image_it.next()) |kv| { + const img = &kv.value_ptr.image; + if (img.isUnloading()) { + img.deinit(self.alloc); + self.images.removeByPtr(kv.key_ptr); + return; + } + if (img.isPending()) try img.upload(self.alloc, &self.api); + } + } + + /// Call this any time the background image path changes. + /// + /// Caller must hold the draw mutex. + fn prepBackgroundImage(self: *Self) !void { + // Then we try to load the background image if we have a path. + if (self.config.bg_image) |p| load_background: { + const path = switch (p) { + .required, .optional => |slice| slice, + }; + + // Open the file + var file = std.fs.openFileAbsolute(path, .{}) catch |err| { + log.warn( + "error opening background image file \"{s}\": {}", + .{ path, err }, + ); + break :load_background; + }; + defer file.close(); + + // Read it + const contents = file.readToEndAlloc( + self.alloc, + std.math.maxInt(u32), // Max size of 4 GiB, for now. + ) catch |err| { + log.warn( + "error reading background image file \"{s}\": {}", + .{ path, err }, + ); + break :load_background; + }; + defer self.alloc.free(contents); + + // Figure out what type it probably is. + const file_type = switch (FileType.detect(contents)) { + .unknown => FileType.guessFromExtension( + std.fs.path.extension(path), + ), + else => |t| t, + }; + + // Decode it if we know how. + const image_data = switch (file_type) { + .png => try wuffs.png.decode(self.alloc, contents), + .jpeg => try wuffs.jpeg.decode(self.alloc, contents), + .unknown => { + log.warn( + "Cannot determine file type for background image file \"{s}\"!", + .{path}, + ); + break :load_background; + }, + else => |f| { + log.warn( + "Unsupported file type {} for background image file \"{s}\"!", + .{ f, path }, + ); + break :load_background; + }, + }; + + const image: imagepkg.Image = .{ + .pending = .{ + .width = image_data.width, + .height = image_data.height, + .pixel_format = .rgba, + .data = image_data.data.ptr, + }, + }; + + // If we have an existing background image, replace it. + // Otherwise, set this as our background image directly. + if (self.bg_image) |*img| { + try img.markForReplace(self.alloc, image); + } else { + self.bg_image = image; + } + } else { + // If we don't have a background image path, mark our + // background image for unload if we currently have one. + if (self.bg_image) |*img| img.markForUnload(); + } + } + + fn uploadBackgroundImage(self: *Self) !void { + // Make sure our bg image is uploaded if it needs to be. + if (self.bg_image) |*bg| { + if (bg.isUnloading()) { + bg.deinit(self.alloc); + self.bg_image = null; + return; + } + if (bg.isPending()) try bg.upload(self.alloc, &self.api); + } + } + /// Update the configuration. pub fn changeConfig(self: *Self, config: *DerivedConfig) !void { self.draw_mutex.lock(); @@ -1869,12 +2082,33 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self.default_cursor_color = if (!config.cursor_invert) config.cursor_color else null; self.cursor_invert = config.cursor_invert; + const bg_image_config_changed = + self.config.bg_image_fit != config.bg_image_fit or + self.config.bg_image_position != config.bg_image_position or + self.config.bg_image_repeat != config.bg_image_repeat or + self.config.bg_image_opacity != config.bg_image_opacity; + + const bg_image_changed = + if (self.config.bg_image) |old| + if (config.bg_image) |new| + !old.equal(new) + else + true + else + config.bg_image != null; + const old_blending = self.config.blending; const custom_shaders_changed = !self.config.custom_shaders.equal(config.custom_shaders); self.config.deinit(); self.config = config.*; + // If our background image path changed, prepare the new bg image. + if (bg_image_changed) try self.prepBackgroundImage(); + + // If our background image config changed, update the vertex buffer. + if (bg_image_config_changed) self.updateBgImageBuffer(); + // Reset our viewport to force a rebuild, in case of a font change. self.cells_viewport = null; @@ -1944,14 +2178,50 @@ pub fn Renderer(comptime GraphicsAPI: type) type { @floatFromInt(blank.bottom), @floatFromInt(blank.left), }; + self.uniforms.screen_size = .{ + @floatFromInt(self.size.screen.width), + @floatFromInt(self.size.screen.height), + }; + } + + /// Update the background image vertex buffer (CPU-side). + /// + /// This should be called if and when configs change that + /// could affect the background image. + /// + /// Caller must hold the draw mutex. + fn updateBgImageBuffer(self: *Self) void { + self.bg_image_buffer = .{ + .opacity = self.config.bg_image_opacity, + .info = .{ + .position = switch (self.config.bg_image_position) { + .@"top-left" => .tl, + .@"top-center" => .tc, + .@"top-right" => .tr, + .@"center-left" => .ml, + .@"center-center", .center => .mc, + .@"center-right" => .mr, + .@"bottom-left" => .bl, + .@"bottom-center" => .bc, + .@"bottom-right" => .br, + }, + .fit = switch (self.config.bg_image_fit) { + .contain => .contain, + .cover => .cover, + .stretch => .stretch, + .none => .none, + }, + .repeat = self.config.bg_image_repeat, + }, + }; + // Signal that the buffer was modified. + self.bg_image_buffer_modified +%= 1; } /// Update uniforms for the custom shaders, if necessary. /// /// This should be called exactly once per frame, inside `drawFrame`. - fn updateCustomShaderUniforms( - self: *Self, - ) !void { + fn updateCustomShaderUniforms(self: *Self) !void { // We only need to do this if we have custom shaders. if (!self.has_custom_shaders) return; @@ -2199,13 +2469,15 @@ pub fn Renderer(comptime GraphicsAPI: type) type { } // Iterator of runs for shaping. - var run_iter = self.font_shaper.runIterator( - self.font_grid, - screen, - row, - row_selection, - if (shape_cursor) screen.cursor.x else null, - ); + var run_iter_opts: font.shape.RunOptions = .{ + .grid = self.font_grid, + .screen = screen, + .row = row, + .selection = row_selection, + .cursor_x = if (shape_cursor) screen.cursor.x else null, + }; + run_iter_opts.applyBreakConfig(self.config.font_shaping_break); + var run_iter = self.font_shaper.runIterator(run_iter_opts); var shaper_run: ?font.shape.TextRun = try run_iter.next(self.alloc); var shaper_cells: ?[]const font.shape.Cell = null; var shaper_cells_i: usize = 0; @@ -2378,8 +2650,6 @@ pub fn Renderer(comptime GraphicsAPI: type) type { const bg_alpha: u8 = bg_alpha: { const default: u8 = 255; - if (self.config.background_opacity >= 1) break :bg_alpha default; - // Cells that are selected should be fully opaque. if (selected) break :bg_alpha default; @@ -2387,12 +2657,12 @@ pub fn Renderer(comptime GraphicsAPI: type) type { if (style.flags.inverse) break :bg_alpha default; // Cells that have an explicit bg color should be fully opaque. - if (bg_style != null) { - break :bg_alpha default; - } + if (bg_style != null) break :bg_alpha default; - // Otherwise, we use the configured background opacity. - break :bg_alpha @intFromFloat(@round(self.config.background_opacity * 255.0)); + // Otherwise, we won't draw the bg for this cell, + // we'll let the already-drawn background color + // show through. + break :bg_alpha 0; }; self.cells.bgCell(y, x).* = .{ @@ -2769,7 +3039,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { return; } - const mode: shaderpkg.CellText.Mode = switch (try fgMode( + const mode: shaderpkg.CellText.Mode = switch (fgMode( render.presentation, cell_pin, )) { diff --git a/src/renderer/image.zig b/src/renderer/image.zig new file mode 100644 index 000000000..d89c46730 --- /dev/null +++ b/src/renderer/image.zig @@ -0,0 +1,302 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const assert = std.debug.assert; +const wuffs = @import("wuffs"); + +const Renderer = @import("../renderer.zig").Renderer; +const GraphicsAPI = Renderer.API; +const Texture = GraphicsAPI.Texture; + +/// Represents a single image placement on the grid. +/// A placement is a request to render an instance of an image. +pub const Placement = struct { + /// The image being rendered. This MUST be in the image map. + image_id: u32, + + /// The grid x/y where this placement is located. + x: i32, + y: i32, + z: i32, + + /// The width/height of the placed image. + width: u32, + height: u32, + + /// The offset in pixels from the top left of the cell. + /// This is clamped to the size of a cell. + cell_offset_x: u32, + cell_offset_y: u32, + + /// The source rectangle of the placement. + source_x: u32, + source_y: u32, + source_width: u32, + source_height: u32, +}; + +/// The map used for storing images. +pub const ImageMap = std.AutoHashMapUnmanaged(u32, struct { + image: Image, + transmit_time: std.time.Instant, +}); + +/// The state for a single image that is to be rendered. +pub const Image = union(enum) { + /// The image data is pending upload to the GPU. + /// + /// This data is owned by this union so it must be freed once uploaded. + pending: Pending, + + /// This is the same as the pending states but there is + /// a texture already allocated that we want to replace. + replace: Replace, + + /// The image is uploaded and ready to be used. + ready: Texture, + + /// The image isn't uploaded yet but is scheduled to be unloaded. + unload_pending: Pending, + /// The image is uploaded and is scheduled to be unloaded. + unload_ready: Texture, + /// The image is uploaded and scheduled to be replaced + /// with new data, but it's also scheduled to be unloaded. + unload_replace: Replace, + + pub const Replace = struct { + texture: Texture, + pending: Pending, + }; + + /// Pending image data that needs to be uploaded to the GPU. + pub const Pending = struct { + height: u32, + width: u32, + pixel_format: PixelFormat, + + /// Data is always expected to be (width * height * bpp). + data: [*]u8, + + pub fn dataSlice(self: Pending) []u8 { + return self.data[0..self.len()]; + } + + pub fn len(self: Pending) usize { + return self.width * self.height * self.pixel_format.bpp(); + } + + pub const PixelFormat = enum { + /// 1 byte per pixel grayscale. + gray, + /// 2 bytes per pixel grayscale + alpha. + gray_alpha, + /// 3 bytes per pixel RGB. + rgb, + /// 3 bytes per pixel BGR. + bgr, + /// 4 byte per pixel RGBA. + rgba, + /// 4 byte per pixel BGRA. + bgra, + + /// Get bytes per pixel for this format. + pub inline fn bpp(self: PixelFormat) usize { + return switch (self) { + .gray => 1, + .gray_alpha => 2, + .rgb => 3, + .bgr => 3, + .rgba => 4, + .bgra => 4, + }; + } + }; + }; + + pub fn deinit(self: Image, alloc: Allocator) void { + switch (self) { + .pending, + .unload_pending, + => |p| alloc.free(p.dataSlice()), + + .replace, .unload_replace => |r| { + alloc.free(r.pending.dataSlice()); + r.texture.deinit(); + }, + + .ready, + .unload_ready, + => |t| t.deinit(), + } + } + + /// Mark this image for unload whatever state it is in. + pub fn markForUnload(self: *Image) void { + self.* = switch (self.*) { + .unload_pending, + .unload_replace, + .unload_ready, + => return, + + .ready => |t| .{ .unload_ready = t }, + .pending => |p| .{ .unload_pending = p }, + .replace => |r| .{ .unload_replace = r }, + }; + } + + /// Mark the current image to be replaced with a pending one. This will + /// attempt to update the existing texture if we have one, otherwise it + /// will act like a new upload. + pub fn markForReplace(self: *Image, alloc: Allocator, img: Image) !void { + assert(img.isPending()); + + // If we have pending data right now, free it. + if (self.getPending()) |p| { + alloc.free(p.dataSlice()); + } + // If we have an existing texture, use it in the replace. + if (self.getTexture()) |t| { + self.* = .{ .replace = .{ + .texture = t, + .pending = img.getPending().?, + } }; + return; + } + // Otherwise we just become a pending image. + self.* = .{ .pending = img.getPending().? }; + } + + /// Returns true if this image is pending upload. + pub fn isPending(self: Image) bool { + return self.getPending() != null; + } + + /// Returns true if this image has an associated texture. + pub fn hasTexture(self: Image) bool { + return self.getTexture() != null; + } + + /// Returns true if this image is marked for unload. + pub fn isUnloading(self: Image) bool { + return switch (self) { + .unload_pending, + .unload_replace, + .unload_ready, + => true, + + .pending, + .replace, + .ready, + => false, + }; + } + + /// Converts the image data to a format that can be uploaded to the GPU. + /// If the data is already in a format that can be uploaded, this is a + /// no-op. + pub fn convert(self: *Image, alloc: Allocator) wuffs.Error!void { + const p = self.getPendingPointer().?; + // As things stand, we currently convert all images to RGBA before + // uploading to the GPU. This just makes things easier. In the future + // we may want to support other formats. + if (p.pixel_format == .rgba) return; + // If the pending data isn't RGBA we'll need to swizzle it. + const data = p.dataSlice(); + const rgba = try switch (p.pixel_format) { + .gray => wuffs.swizzle.gToRgba(alloc, data), + .gray_alpha => wuffs.swizzle.gaToRgba(alloc, data), + .rgb => wuffs.swizzle.rgbToRgba(alloc, data), + .bgr => wuffs.swizzle.bgrToRgba(alloc, data), + .rgba => unreachable, + .bgra => wuffs.swizzle.bgraToRgba(alloc, data), + }; + alloc.free(data); + p.data = rgba.ptr; + p.pixel_format = .rgba; + } + + /// Prepare the pending image data for upload to the GPU. + /// This doesn't need GPU access so is safe to call any time. + pub fn prepForUpload(self: *Image, alloc: Allocator) !void { + assert(self.isPending()); + + try self.convert(alloc); + } + + /// Upload the pending image to the GPU and + /// change the state of this image to ready. + pub fn upload( + self: *Image, + alloc: Allocator, + api: *const GraphicsAPI, + ) !void { + assert(self.isPending()); + + try self.prepForUpload(alloc); + + // Get our pending info + const p = self.getPending().?; + + // Create our texture + const texture = try Texture.init( + api.imageTextureOptions(.rgba, true), + @intCast(p.width), + @intCast(p.height), + p.dataSlice(), + ); + + // Uploaded. We can now clear our data and change our state. + // + // NOTE: For the `replace` state, this will free the old texture. + // We don't currently actually replace the existing texture + // in-place but that is an optimization we can do later. + self.deinit(alloc); + self.* = .{ .ready = texture }; + } + + /// Returns any pending image data for this image that requires upload. + /// + /// If there is no pending data to upload, returns null. + fn getPending(self: Image) ?Pending { + return switch (self) { + .pending, + .unload_pending, + => |p| p, + + .replace, + .unload_replace, + => |r| r.pending, + + else => null, + }; + } + + /// Returns the texture for this image. + /// + /// If there is no texture for it yet, returns null. + fn getTexture(self: Image) ?Texture { + return switch (self) { + .ready, + .unload_ready, + => |t| t, + + .replace, + .unload_replace, + => |r| r.texture, + + else => null, + }; + } + + // Same as getPending but returns a pointer instead of a copy. + fn getPendingPointer(self: *Image) ?*Pending { + return switch (self.*) { + .pending => return &self.pending, + .unload_pending => return &self.unload_pending, + + .replace => return &self.replace.pending, + .unload_replace => return &self.unload_replace.pending, + + else => null, + }; + } +}; diff --git a/src/renderer/metal/Pipeline.zig b/src/renderer/metal/Pipeline.zig index f72aeb2e1..0b8e99159 100644 --- a/src/renderer/metal/Pipeline.zig +++ b/src/renderer/metal/Pipeline.zig @@ -160,6 +160,7 @@ fn autoAttribute(T: type, attrs: objc.Object) void { const offset = @offsetOf(T, field.name); const FT = switch (@typeInfo(field.type)) { + .@"struct" => |e| e.backing_integer.?, .@"enum" => |e| e.tag_type, else => field.type, }; @@ -169,13 +170,17 @@ fn autoAttribute(T: type, attrs: objc.Object) void { [4]u8 => mtl.MTLVertexFormat.uchar4, [2]u16 => mtl.MTLVertexFormat.ushort2, [2]i16 => mtl.MTLVertexFormat.short2, + f32 => mtl.MTLVertexFormat.float, [2]f32 => mtl.MTLVertexFormat.float2, [4]f32 => mtl.MTLVertexFormat.float4, + i32 => mtl.MTLVertexFormat.int, [2]i32 => mtl.MTLVertexFormat.int2, + [4]i32 => mtl.MTLVertexFormat.int2, u32 => mtl.MTLVertexFormat.uint, [2]u32 => mtl.MTLVertexFormat.uint2, [4]u32 => mtl.MTLVertexFormat.uint4, u8 => mtl.MTLVertexFormat.uchar, + i8 => mtl.MTLVertexFormat.char, else => comptime unreachable, }; diff --git a/src/renderer/metal/cell.zig b/src/renderer/metal/cell.zig deleted file mode 100644 index e1bcb7b9f..000000000 --- a/src/renderer/metal/cell.zig +++ /dev/null @@ -1,358 +0,0 @@ -const std = @import("std"); -const assert = std.debug.assert; -const Allocator = std.mem.Allocator; - -const renderer = @import("../../renderer.zig"); -const terminal = @import("../../terminal/main.zig"); -const mtl_shaders = @import("shaders.zig"); - -/// The possible cell content keys that exist. -pub const Key = enum { - bg, - text, - underline, - strikethrough, - overline, - - /// Returns the GPU vertex type for this key. - pub fn CellType(self: Key) type { - return switch (self) { - .bg => mtl_shaders.CellBg, - - .text, - .underline, - .strikethrough, - .overline, - => mtl_shaders.CellText, - }; - } -}; - -/// A pool of ArrayLists with methods for bulk operations. -fn ArrayListPool(comptime T: type) type { - return struct { - const Self = ArrayListPool(T); - const ArrayListT = std.ArrayListUnmanaged(T); - - // An array containing the lists that belong to this pool. - lists: []ArrayListT = &[_]ArrayListT{}, - - // The pool will be initialized with empty ArrayLists. - pub fn init(alloc: Allocator, list_count: usize, initial_capacity: usize) !Self { - const self: Self = .{ - .lists = try alloc.alloc(ArrayListT, list_count), - }; - - for (self.lists) |*list| { - list.* = try .initCapacity(alloc, initial_capacity); - } - - return self; - } - - pub fn deinit(self: *Self, alloc: Allocator) void { - for (self.lists) |*list| { - list.deinit(alloc); - } - alloc.free(self.lists); - } - - /// Clear all lists in the pool. - pub fn reset(self: *Self) void { - for (self.lists) |*list| { - list.clearRetainingCapacity(); - } - } - }; -} - -/// The contents of all the cells in the terminal. -/// -/// The goal of this data structure is to allow for efficient row-wise -/// clearing of data from the GPU buffers, to allow for row-wise dirty -/// tracking to eliminate the overhead of rebuilding the GPU buffers -/// each frame. -/// -/// Must be initialized by resizing before calling any operations. -pub const Contents = struct { - size: renderer.GridSize = .{ .rows = 0, .columns = 0 }, - - /// Flat array containing cell background colors for the terminal grid. - /// - /// Indexed as `bg_cells[row * size.columns + col]`. - /// - /// Prefer accessing with `Contents.bgCell(row, col).*` instead - /// of directly indexing in order to avoid integer size bugs. - bg_cells: []mtl_shaders.CellBg = undefined, - - /// The ArrayListPool which holds all of the foreground cells. When sized - /// with Contents.resize the individual ArrayLists are given enough room - /// that they can hold a single row with #cols glyphs, underlines, and - /// strikethroughs; however, appendAssumeCapacity MUST NOT be used since - /// it is possible to exceed this with combining glyphs that add a glyph - /// but take up no column since they combine with the previous one, as - /// well as with fonts that perform multi-substitutions for glyphs, which - /// can result in a similar situation where multiple glyphs reside in the - /// same column. - /// - /// Allocations should nevertheless be exceedingly rare since hitting the - /// initial capacity of a list would require a row filled with underlined - /// struck through characters, at least one of which is a multi-glyph - /// composite. - /// - /// Rows are indexed as Contents.fg_rows[y + 1], because the first list in - /// the pool is reserved for the cursor, which must be the first item in - /// the buffer. - /// - /// Must be initialized by calling resize on the Contents struct before - /// calling any operations. - fg_rows: ArrayListPool(mtl_shaders.CellText) = .{}, - - pub fn deinit(self: *Contents, alloc: Allocator) void { - alloc.free(self.bg_cells); - self.fg_rows.deinit(alloc); - } - - /// Resize the cell contents for the given grid size. This will - /// always invalidate the entire cell contents. - pub fn resize( - self: *Contents, - alloc: Allocator, - size: renderer.GridSize, - ) !void { - self.size = size; - - const cell_count = @as(usize, size.columns) * @as(usize, size.rows); - - const bg_cells = try alloc.alloc(mtl_shaders.CellBg, cell_count); - errdefer alloc.free(bg_cells); - - @memset(bg_cells, .{ 0, 0, 0, 0 }); - - // The foreground lists can hold 3 types of items: - // - Glyphs - // - Underlines - // - Strikethroughs - // So we give them an initial capacity of size.columns * 3, which will - // avoid any further allocations in the vast majority of cases. Sadly - // we can not assume capacity though, since with combining glyphs that - // form a single grapheme, and multi-substitutions in fonts, the number - // of glyphs in a row is theoretically unlimited. - // - // We have size.rows + 1 lists because index 0 is used for a special - // list containing the cursor cell which needs to be first in the buffer. - var fg_rows = try ArrayListPool(mtl_shaders.CellText).init(alloc, size.rows + 1, size.columns * 3); - errdefer fg_rows.deinit(alloc); - - alloc.free(self.bg_cells); - self.fg_rows.deinit(alloc); - - self.bg_cells = bg_cells; - self.fg_rows = fg_rows; - - // We don't need 3*cols worth of cells for the cursor list, so we can - // replace it with a smaller list. This is technically a tiny bit of - // extra work but resize is not a hot function so it's worth it to not - // waste the memory. - self.fg_rows.lists[0].deinit(alloc); - self.fg_rows.lists[0] = try std.ArrayListUnmanaged(mtl_shaders.CellText).initCapacity(alloc, 1); - } - - /// Reset the cell contents to an empty state without resizing. - pub fn reset(self: *Contents) void { - @memset(self.bg_cells, .{ 0, 0, 0, 0 }); - self.fg_rows.reset(); - } - - /// Set the cursor value. If the value is null then the cursor is hidden. - pub fn setCursor(self: *Contents, v: ?mtl_shaders.CellText) void { - self.fg_rows.lists[0].clearRetainingCapacity(); - - if (v) |cell| { - self.fg_rows.lists[0].appendAssumeCapacity(cell); - } - } - - /// Access a background cell. Prefer this function over direct indexing - /// of `bg_cells` in order to avoid integer size bugs causing overflows. - pub inline fn bgCell(self: *Contents, row: usize, col: usize) *mtl_shaders.CellBg { - return &self.bg_cells[row * self.size.columns + col]; - } - - /// Add a cell to the appropriate list. Adding the same cell twice will - /// result in duplication in the vertex buffer. The caller should clear - /// the corresponding row with Contents.clear to remove old cells first. - pub fn add( - self: *Contents, - alloc: Allocator, - comptime key: Key, - cell: key.CellType(), - ) !void { - const y = cell.grid_pos[1]; - - assert(y < self.size.rows); - - switch (key) { - .bg => comptime unreachable, - - .text, - .underline, - .strikethrough, - .overline, - // We have a special list containing the cursor cell at the start - // of our fg row pool, so we need to add 1 to the y to get the - // correct index. - => try self.fg_rows.lists[y + 1].append(alloc, cell), - } - } - - /// Clear all of the cell contents for a given row. - pub fn clear(self: *Contents, y: terminal.size.CellCountInt) void { - assert(y < self.size.rows); - - @memset(self.bg_cells[@as(usize, y) * self.size.columns ..][0..self.size.columns], .{ 0, 0, 0, 0 }); - - // We have a special list containing the cursor cell at the start - // of our fg row pool, so we need to add 1 to the y to get the - // correct index. - self.fg_rows.lists[y + 1].clearRetainingCapacity(); - } -}; - -test Contents { - const testing = std.testing; - const alloc = testing.allocator; - - const rows = 10; - const cols = 10; - - var c: Contents = .{}; - try c.resize(alloc, .{ .rows = rows, .columns = cols }); - defer c.deinit(alloc); - - // We should start off empty after resizing. - for (0..rows) |y| { - try testing.expect(c.fg_rows.lists[y + 1].items.len == 0); - for (0..cols) |x| { - try testing.expectEqual(.{ 0, 0, 0, 0 }, c.bgCell(y, x).*); - } - } - // And the cursor row should have a capacity of 1 and also be empty. - try testing.expect(c.fg_rows.lists[0].capacity == 1); - try testing.expect(c.fg_rows.lists[0].items.len == 0); - - // Add some contents. - const bg_cell: mtl_shaders.CellBg = .{ 0, 0, 0, 1 }; - const fg_cell: mtl_shaders.CellText = .{ - .mode = .fg, - .grid_pos = .{ 4, 1 }, - .color = .{ 0, 0, 0, 1 }, - }; - c.bgCell(1, 4).* = bg_cell; - try c.add(alloc, .text, fg_cell); - try testing.expectEqual(bg_cell, c.bgCell(1, 4).*); - // The fg row index is offset by 1 because of the cursor list. - try testing.expectEqual(fg_cell, c.fg_rows.lists[2].items[0]); - - // And we should be able to clear it. - c.clear(1); - for (0..rows) |y| { - try testing.expect(c.fg_rows.lists[y + 1].items.len == 0); - for (0..cols) |x| { - try testing.expectEqual(.{ 0, 0, 0, 0 }, c.bgCell(y, x).*); - } - } - - // Add a cursor. - const cursor_cell: mtl_shaders.CellText = .{ - .mode = .cursor, - .grid_pos = .{ 2, 3 }, - .color = .{ 0, 0, 0, 1 }, - }; - c.setCursor(cursor_cell); - try testing.expectEqual(cursor_cell, c.fg_rows.lists[0].items[0]); - - // And remove it. - c.setCursor(null); - try testing.expectEqual(0, c.fg_rows.lists[0].items.len); -} - -test "Contents clear retains other content" { - const testing = std.testing; - const alloc = testing.allocator; - - const rows = 10; - const cols = 10; - - var c: Contents = .{}; - try c.resize(alloc, .{ .rows = rows, .columns = cols }); - defer c.deinit(alloc); - - // Set some contents - // bg and fg cells in row 1 - const bg_cell_1: mtl_shaders.CellBg = .{ 0, 0, 0, 1 }; - const fg_cell_1: mtl_shaders.CellText = .{ - .mode = .fg, - .grid_pos = .{ 4, 1 }, - .color = .{ 0, 0, 0, 1 }, - }; - c.bgCell(1, 4).* = bg_cell_1; - try c.add(alloc, .text, fg_cell_1); - // bg and fg cells in row 2 - const bg_cell_2: mtl_shaders.CellBg = .{ 0, 0, 0, 1 }; - const fg_cell_2: mtl_shaders.CellText = .{ - .mode = .fg, - .grid_pos = .{ 4, 2 }, - .color = .{ 0, 0, 0, 1 }, - }; - c.bgCell(2, 4).* = bg_cell_2; - try c.add(alloc, .text, fg_cell_2); - - // Clear row 1, this should leave row 2 untouched - c.clear(1); - - // Row 2 should still contain its cells. - try testing.expectEqual(bg_cell_2, c.bgCell(2, 4).*); - // Fg row index is +1 because of cursor list at start - try testing.expectEqual(fg_cell_2, c.fg_rows.lists[3].items[0]); -} - -test "Contents clear last added content" { - const testing = std.testing; - const alloc = testing.allocator; - - const rows = 10; - const cols = 10; - - var c: Contents = .{}; - try c.resize(alloc, .{ .rows = rows, .columns = cols }); - defer c.deinit(alloc); - - // Set some contents - // bg and fg cells in row 1 - const bg_cell_1: mtl_shaders.CellBg = .{ 0, 0, 0, 1 }; - const fg_cell_1: mtl_shaders.CellText = .{ - .mode = .fg, - .grid_pos = .{ 4, 1 }, - .color = .{ 0, 0, 0, 1 }, - }; - c.bgCell(1, 4).* = bg_cell_1; - try c.add(alloc, .text, fg_cell_1); - // bg and fg cells in row 2 - const bg_cell_2: mtl_shaders.CellBg = .{ 0, 0, 0, 1 }; - const fg_cell_2: mtl_shaders.CellText = .{ - .mode = .fg, - .grid_pos = .{ 4, 2 }, - .color = .{ 0, 0, 0, 1 }, - }; - c.bgCell(2, 4).* = bg_cell_2; - try c.add(alloc, .text, fg_cell_2); - - // Clear row 2, this should leave row 1 untouched - c.clear(2); - - // Row 1 should still contain its cells. - try testing.expectEqual(bg_cell_1, c.bgCell(1, 4).*); - // Fg row index is +1 because of cursor list at start - try testing.expectEqual(fg_cell_1, c.fg_rows.lists[2].items[0]); -} diff --git a/src/renderer/metal/image.zig b/src/renderer/metal/image.zig deleted file mode 100644 index 1bfa3c621..000000000 --- a/src/renderer/metal/image.zig +++ /dev/null @@ -1,424 +0,0 @@ -const std = @import("std"); -const Allocator = std.mem.Allocator; -const assert = std.debug.assert; -const objc = @import("objc"); -const wuffs = @import("wuffs"); - -const Metal = @import("../Metal.zig"); -const Texture = Metal.Texture; - -const mtl = @import("api.zig"); - -/// Represents a single image placement on the grid. A placement is a -/// request to render an instance of an image. -pub const Placement = struct { - /// The image being rendered. This MUST be in the image map. - image_id: u32, - - /// The grid x/y where this placement is located. - x: i32, - y: i32, - z: i32, - - /// The width/height of the placed image. - width: u32, - height: u32, - - /// The offset in pixels from the top left of the cell. - /// This is clamped to the size of a cell. - cell_offset_x: u32, - cell_offset_y: u32, - - /// The source rectangle of the placement. - source_x: u32, - source_y: u32, - source_width: u32, - source_height: u32, -}; - -/// The map used for storing images. -pub const ImageMap = std.AutoHashMapUnmanaged(u32, struct { - image: Image, - transmit_time: std.time.Instant, -}); - -/// The state for a single image that is to be rendered. The image can be -/// pending upload or ready to use with a texture. -pub const Image = union(enum) { - /// The image is pending upload to the GPU. The different keys are - /// different formats since some formats aren't accepted by the GPU - /// and require conversion. - /// - /// This data is owned by this union so it must be freed once the - /// image is uploaded. - pending_gray: Pending, - pending_gray_alpha: Pending, - pending_rgb: Pending, - pending_rgba: Pending, - - /// This is the same as the pending states but there is a texture - /// already allocated that we want to replace. - replace_gray: Replace, - replace_gray_alpha: Replace, - replace_rgb: Replace, - replace_rgba: Replace, - - /// The image is uploaded and ready to be used. - ready: Texture, - - /// The image is uploaded but is scheduled to be unloaded. - unload_pending: []u8, - unload_ready: Texture, - unload_replace: struct { []u8, Texture }, - - pub const Replace = struct { - texture: Texture, - pending: Pending, - }; - - /// Pending image data that needs to be uploaded to the GPU. - pub const Pending = struct { - height: u32, - width: u32, - - /// Data is always expected to be (width * height * depth). Depth - /// is based on the union key. - data: [*]u8, - - pub fn dataSlice(self: Pending, d: u32) []u8 { - return self.data[0..self.len(d)]; - } - - pub fn len(self: Pending, d: u32) u32 { - return self.width * self.height * d; - } - }; - - pub fn deinit(self: Image, alloc: Allocator) void { - switch (self) { - .pending_gray => |p| alloc.free(p.dataSlice(1)), - .pending_gray_alpha => |p| alloc.free(p.dataSlice(2)), - .pending_rgb => |p| alloc.free(p.dataSlice(3)), - .pending_rgba => |p| alloc.free(p.dataSlice(4)), - .unload_pending => |data| alloc.free(data), - - .replace_gray => |r| { - alloc.free(r.pending.dataSlice(1)); - r.texture.deinit(); - }, - - .replace_gray_alpha => |r| { - alloc.free(r.pending.dataSlice(2)); - r.texture.deinit(); - }, - - .replace_rgb => |r| { - alloc.free(r.pending.dataSlice(3)); - r.texture.deinit(); - }, - - .replace_rgba => |r| { - alloc.free(r.pending.dataSlice(4)); - r.texture.deinit(); - }, - - .unload_replace => |r| { - alloc.free(r[0]); - r[1].deinit(); - }, - - .ready, - .unload_ready, - => |t| t.deinit(), - } - } - - /// Mark this image for unload whatever state it is in. - pub fn markForUnload(self: *Image) void { - self.* = switch (self.*) { - .unload_pending, - .unload_replace, - .unload_ready, - => return, - - .ready => |obj| .{ .unload_ready = obj }, - .pending_gray => |p| .{ .unload_pending = p.dataSlice(1) }, - .pending_gray_alpha => |p| .{ .unload_pending = p.dataSlice(2) }, - .pending_rgb => |p| .{ .unload_pending = p.dataSlice(3) }, - .pending_rgba => |p| .{ .unload_pending = p.dataSlice(4) }, - .replace_gray => |r| .{ .unload_replace = .{ - r.pending.dataSlice(1), r.texture, - } }, - .replace_gray_alpha => |r| .{ .unload_replace = .{ - r.pending.dataSlice(2), r.texture, - } }, - .replace_rgb => |r| .{ .unload_replace = .{ - r.pending.dataSlice(3), r.texture, - } }, - .replace_rgba => |r| .{ .unload_replace = .{ - r.pending.dataSlice(4), r.texture, - } }, - }; - } - - /// Replace the currently pending image with a new one. This will - /// attempt to update the existing texture if it is already allocated. - /// If the texture is not allocated, this will act like a new upload. - /// - /// This function only marks the image for replace. The actual logic - /// to replace is done later. - pub fn markForReplace(self: *Image, alloc: Allocator, img: Image) !void { - assert(img.pending() != null); - - // Get our existing texture. This switch statement will also handle - // scenarios where there is no existing texture and we can modify - // the self pointer directly. - const existing: Texture = switch (self.*) { - // For pending, we can free the old data and become pending - // ourselves. - .pending_gray => |p| { - alloc.free(p.dataSlice(1)); - self.* = img; - return; - }, - - .pending_gray_alpha => |p| { - alloc.free(p.dataSlice(2)); - self.* = img; - return; - }, - - .pending_rgb => |p| { - alloc.free(p.dataSlice(3)); - self.* = img; - return; - }, - - .pending_rgba => |p| { - alloc.free(p.dataSlice(4)); - self.* = img; - return; - }, - - // If we're marked for unload but we just have pending data, - // this behaves the same as a normal "pending": free the data, - // become new pending. - .unload_pending => |data| { - alloc.free(data); - self.* = img; - return; - }, - - .unload_replace => |r| existing: { - alloc.free(r[0]); - break :existing r[1]; - }, - - // If we were already pending a replacement, then we free our - // existing pending data and use the same texture. - .replace_gray => |r| existing: { - alloc.free(r.pending.dataSlice(1)); - break :existing r.texture; - }, - - .replace_gray_alpha => |r| existing: { - alloc.free(r.pending.dataSlice(2)); - break :existing r.texture; - }, - - .replace_rgb => |r| existing: { - alloc.free(r.pending.dataSlice(3)); - break :existing r.texture; - }, - - .replace_rgba => |r| existing: { - alloc.free(r.pending.dataSlice(4)); - break :existing r.texture; - }, - - // For both ready and unload_ready, we need to replace the - // texture. We can't do that here, so we just mark ourselves - // for replacement. - .ready, .unload_ready => |tex| tex, - }; - - // We now have an existing texture, so set the proper replace key. - self.* = switch (img) { - .pending_gray => |p| .{ .replace_gray = .{ - .texture = existing, - .pending = p, - } }, - - .pending_gray_alpha => |p| .{ .replace_gray_alpha = .{ - .texture = existing, - .pending = p, - } }, - - .pending_rgb => |p| .{ .replace_rgb = .{ - .texture = existing, - .pending = p, - } }, - - .pending_rgba => |p| .{ .replace_rgba = .{ - .texture = existing, - .pending = p, - } }, - - else => unreachable, - }; - } - - /// Returns true if this image is pending upload. - pub fn isPending(self: Image) bool { - return self.pending() != null; - } - - /// Returns true if this image is pending an unload. - pub fn isUnloading(self: Image) bool { - return switch (self) { - .unload_pending, - .unload_ready, - => true, - - .ready, - .pending_rgb, - .pending_rgba, - => false, - }; - } - - /// Converts the image data to a format that can be uploaded to the GPU. - /// If the data is already in a format that can be uploaded, this is a - /// no-op. - pub fn convert(self: *Image, alloc: Allocator) !void { - switch (self.*) { - .ready, - .unload_pending, - .unload_replace, - .unload_ready, - => unreachable, // invalid - - .pending_rgba, - .replace_rgba, - => {}, // ready - - // RGB needs to be converted to RGBA because Metal textures - // don't support RGB. - .pending_rgb => |*p| { - const data = p.dataSlice(3); - const rgba = try wuffs.swizzle.rgbToRgba(alloc, data); - alloc.free(data); - p.data = rgba.ptr; - self.* = .{ .pending_rgba = p.* }; - }, - - .replace_rgb => |*r| { - const data = r.pending.dataSlice(3); - const rgba = try wuffs.swizzle.rgbToRgba(alloc, data); - alloc.free(data); - r.pending.data = rgba.ptr; - self.* = .{ .replace_rgba = r.* }; - }, - - // Gray and Gray+Alpha need to be converted to RGBA, too. - .pending_gray => |*p| { - const data = p.dataSlice(1); - const rgba = try wuffs.swizzle.gToRgba(alloc, data); - alloc.free(data); - p.data = rgba.ptr; - self.* = .{ .pending_rgba = p.* }; - }, - - .replace_gray => |*r| { - const data = r.pending.dataSlice(2); - const rgba = try wuffs.swizzle.gToRgba(alloc, data); - alloc.free(data); - r.pending.data = rgba.ptr; - self.* = .{ .replace_rgba = r.* }; - }, - - .pending_gray_alpha => |*p| { - const data = p.dataSlice(2); - const rgba = try wuffs.swizzle.gaToRgba(alloc, data); - alloc.free(data); - p.data = rgba.ptr; - self.* = .{ .pending_rgba = p.* }; - }, - - .replace_gray_alpha => |*r| { - const data = r.pending.dataSlice(2); - const rgba = try wuffs.swizzle.gaToRgba(alloc, data); - alloc.free(data); - r.pending.data = rgba.ptr; - self.* = .{ .replace_rgba = r.* }; - }, - } - } - - /// Upload the pending image to the GPU and change the state of this - /// image to ready. - pub fn upload( - self: *Image, - alloc: Allocator, - metal: *const Metal, - ) !void { - const device = metal.device; - const storage_mode = metal.default_storage_mode; - - // Convert our data if we have to - try self.convert(alloc); - - // Get our pending info - const p = self.pending().?; - - // Create our texture - const texture = try Texture.init( - .{ - .device = device, - .pixel_format = .rgba8unorm_srgb, - .resource_options = .{ - // Indicate that the CPU writes to this resource but never reads it. - .cpu_cache_mode = .write_combined, - .storage_mode = storage_mode, - }, - }, - @intCast(p.width), - @intCast(p.height), - p.data[0 .. p.width * p.height * self.depth()], - ); - - // Uploaded. We can now clear our data and change our state. - // - // NOTE: For "replace_*" states, this will free the old texture. - // We don't currently actually replace the existing texture in-place - // but that is an optimization we can do later. - self.deinit(alloc); - self.* = .{ .ready = texture }; - } - - /// Our pixel depth - fn depth(self: Image) u32 { - return switch (self) { - .pending_rgb => 3, - .pending_rgba => 4, - .replace_rgb => 3, - .replace_rgba => 4, - else => unreachable, - }; - } - - /// Returns true if this image is in a pending state and requires upload. - fn pending(self: Image) ?Pending { - return switch (self) { - .pending_rgb, - .pending_rgba, - => |p| p, - - .replace_rgb, - .replace_rgba, - => |r| r.pending, - - else => null, - }; - } -}; diff --git a/src/renderer/metal/shaders.zig b/src/renderer/metal/shaders.zig index dc5d1122c..9fe0862ed 100644 --- a/src/renderer/metal/shaders.zig +++ b/src/renderer/metal/shaders.zig @@ -10,20 +10,97 @@ const Pipeline = @import("Pipeline.zig"); const log = std.log.scoped(.metal); +const pipeline_descs: []const struct { [:0]const u8, PipelineDescription } = + &.{ + .{ "bg_color", .{ + .vertex_fn = "full_screen_vertex", + .fragment_fn = "bg_color_fragment", + .blending_enabled = false, + } }, + .{ "cell_bg", .{ + .vertex_fn = "full_screen_vertex", + .fragment_fn = "cell_bg_fragment", + .blending_enabled = true, + } }, + .{ "cell_text", .{ + .vertex_attributes = CellText, + .vertex_fn = "cell_text_vertex", + .fragment_fn = "cell_text_fragment", + .step_fn = .per_instance, + .blending_enabled = true, + } }, + .{ "image", .{ + .vertex_attributes = Image, + .vertex_fn = "image_vertex", + .fragment_fn = "image_fragment", + .step_fn = .per_instance, + .blending_enabled = true, + } }, + .{ "bg_image", .{ + .vertex_attributes = BgImage, + .vertex_fn = "bg_image_vertex", + .fragment_fn = "bg_image_fragment", + .step_fn = .per_instance, + .blending_enabled = true, + } }, + }; + +/// All the comptime-known info about a pipeline, so that +/// we can define them ahead-of-time in an ergonomic way. +const PipelineDescription = struct { + vertex_attributes: ?type = null, + vertex_fn: []const u8, + fragment_fn: []const u8, + step_fn: mtl.MTLVertexStepFunction = .per_vertex, + blending_enabled: bool, + + fn initPipeline( + self: PipelineDescription, + device: objc.Object, + library: objc.Object, + pixel_format: mtl.MTLPixelFormat, + ) !Pipeline { + return try .init(self.vertex_attributes, .{ + .device = device, + .vertex_fn = self.vertex_fn, + .fragment_fn = self.fragment_fn, + .vertex_library = library, + .fragment_library = library, + .step_fn = self.step_fn, + .attachments = &.{.{ + .pixel_format = pixel_format, + .blending_enabled = self.blending_enabled, + }}, + }); + } +}; + +/// We create a type for the pipeline collection based on our desc array. +const PipelineCollection = t: { + var fields: [pipeline_descs.len]std.builtin.Type.StructField = undefined; + for (pipeline_descs, 0..) |pipeline, i| { + fields[i] = .{ + .name = pipeline[0], + .type = Pipeline, + .default_value_ptr = null, + .is_comptime = false, + .alignment = @alignOf(Pipeline), + }; + } + break :t @Type(.{ .@"struct" = .{ + .layout = .auto, + .fields = &fields, + .decls = &.{}, + .is_tuple = false, + } }); +}; + /// This contains the state for the shaders used by the Metal renderer. pub const Shaders = struct { library: objc.Object, - /// Renders cell foreground elements (text, decorations). - cell_text_pipeline: Pipeline, - - /// The cell background shader is the shader used to render the - /// background of terminal cells. - cell_bg_pipeline: Pipeline, - - /// The image shader is the shader used to render images for things - /// like the Kitty image protocol. - image_pipeline: Pipeline, + /// Collection of available render pipelines. + pipelines: PipelineCollection, /// Custom shaders to run against the final drawable texture. This /// can be used to apply a lot of effects. Each shader is run in sequence @@ -48,14 +125,24 @@ pub const Shaders = struct { const library = try initLibrary(device); errdefer library.msgSend(void, objc.sel("release"), .{}); - const cell_text_pipeline = try initCellTextPipeline(device, library, pixel_format); - errdefer cell_text_pipeline.deinit(); + var pipelines: PipelineCollection = undefined; - const cell_bg_pipeline = try initCellBgPipeline(device, library, pixel_format); - errdefer cell_bg_pipeline.deinit(); + var initialized_pipelines: usize = 0; - const image_pipeline = try initImagePipeline(device, library, pixel_format); - errdefer image_pipeline.deinit(); + errdefer inline for (pipeline_descs, 0..) |pipeline, i| { + if (i < initialized_pipelines) { + @field(pipelines, pipeline[0]).deinit(); + } + }; + + inline for (pipeline_descs) |pipeline| { + @field(pipelines, pipeline[0]) = try pipeline[1].initPipeline( + device, + library, + pixel_format, + ); + initialized_pipelines += 1; + } const post_pipelines: []const Pipeline = initPostPipelines( alloc, @@ -77,9 +164,7 @@ pub const Shaders = struct { return .{ .library = library, - .cell_text_pipeline = cell_text_pipeline, - .cell_bg_pipeline = cell_bg_pipeline, - .image_pipeline = image_pipeline, + .pipelines = pipelines, .post_pipelines = post_pipelines, }; } @@ -89,9 +174,9 @@ pub const Shaders = struct { self.defunct = true; // Release our primary shaders - self.cell_text_pipeline.deinit(); - self.cell_bg_pipeline.deinit(); - self.image_pipeline.deinit(); + inline for (pipeline_descs) |pipeline| { + @field(self.pipelines, pipeline[0]).deinit(); + } self.library.msgSend(void, objc.sel("release"), .{}); // Release our postprocess shaders @@ -104,15 +189,7 @@ pub const Shaders = struct { } }; -/// Single parameter for the image shader. See shader for field details. -pub const Image = extern struct { - grid_pos: [2]f32, - cell_offset: [2]f32, - source_rect: [4]f32, - dest_size: [2]f32, -}; - -/// The uniforms that are passed to the terminal cell shader. +/// The uniforms that are passed to our shaders. pub const Uniforms = extern struct { // Note: all of the explicit alignments are copied from the // MSL developer reference just so that we can be sure that we got @@ -122,6 +199,9 @@ pub const Uniforms = extern struct { /// This is calculated based on the size of the screen. projection_matrix: math.Mat align(16), + /// Size of the screen (render target) in pixels. + screen_size: [2]f32 align(8), + /// Size of a single cell in pixels, unscaled. cell_size: [2]f32 align(8), @@ -182,6 +262,74 @@ pub const Uniforms = extern struct { }; }; +/// This is a single parameter for the terminal cell shader. +pub const CellText = extern struct { + glyph_pos: [2]u32 align(8) = .{ 0, 0 }, + glyph_size: [2]u32 align(8) = .{ 0, 0 }, + bearings: [2]i16 align(4) = .{ 0, 0 }, + grid_pos: [2]u16 align(4), + color: [4]u8 align(4), + mode: Mode align(1), + constraint_width: u8 align(1) = 0, + + pub const Mode = enum(u8) { + fg = 1, + fg_constrained = 2, + fg_color = 3, + cursor = 4, + fg_powerline = 5, + }; + + test { + // Minimizing the size of this struct is important, + // so we test it in order to be aware of any changes. + try std.testing.expectEqual(32, @sizeOf(CellText)); + } +}; + +/// This is a single parameter for the cell bg shader. +pub const CellBg = [4]u8; + +/// Single parameter for the image shader. See shader for field details. +pub const Image = extern struct { + grid_pos: [2]f32, + cell_offset: [2]f32, + source_rect: [4]f32, + dest_size: [2]f32, +}; + +/// Single parameter for the bg image shader. +pub const BgImage = extern struct { + opacity: f32 align(4), + info: Info align(1), + + pub const Info = packed struct(u8) { + position: Position, + fit: Fit, + repeat: bool, + _padding: u1 = 0, + + pub const Position = enum(u4) { + tl = 0, + tc = 1, + tr = 2, + ml = 3, + mc = 4, + mr = 5, + bl = 6, + bc = 7, + br = 8, + }; + + pub const Fit = enum(u2) { + contain = 0, + cover = 1, + stretch = 2, + none = 3, + }; + }; +}; + /// Initialize the MTLLibrary. A MTLLibrary is a collection of shaders. fn initLibrary(device: objc.Object) !objc.Object { const start = try std.time.Instant.now(); @@ -294,99 +442,6 @@ fn initPostPipeline( }); } -/// This is a single parameter for the terminal cell shader. -pub const CellText = extern struct { - glyph_pos: [2]u32 align(8) = .{ 0, 0 }, - glyph_size: [2]u32 align(8) = .{ 0, 0 }, - bearings: [2]i16 align(4) = .{ 0, 0 }, - grid_pos: [2]u16 align(4), - color: [4]u8 align(4), - mode: Mode align(1), - constraint_width: u8 align(1) = 0, - - pub const Mode = enum(u8) { - fg = 1, - fg_constrained = 2, - fg_color = 3, - cursor = 4, - fg_powerline = 5, - }; - - test { - // Minimizing the size of this struct is important, - // so we test it in order to be aware of any changes. - try std.testing.expectEqual(32, @sizeOf(CellText)); - } -}; - -/// Initialize the cell render pipeline for our shader library. -fn initCellTextPipeline( - device: objc.Object, - library: objc.Object, - pixel_format: mtl.MTLPixelFormat, -) !Pipeline { - return try Pipeline.init(CellText, .{ - .device = device, - .vertex_fn = "cell_text_vertex", - .fragment_fn = "cell_text_fragment", - .vertex_library = library, - .fragment_library = library, - .step_fn = .per_instance, - .attachments = &.{ - .{ - .pixel_format = pixel_format, - .blending_enabled = true, - }, - }, - }); -} - -/// This is a single parameter for the cell bg shader. -pub const CellBg = [4]u8; - -/// Initialize the cell background render pipeline for our shader library. -fn initCellBgPipeline( - device: objc.Object, - library: objc.Object, - pixel_format: mtl.MTLPixelFormat, -) !Pipeline { - return try Pipeline.init(null, .{ - .device = device, - .vertex_fn = "cell_bg_vertex", - .fragment_fn = "cell_bg_fragment", - .vertex_library = library, - .fragment_library = library, - .attachments = &.{ - .{ - .pixel_format = pixel_format, - .blending_enabled = false, - }, - }, - }); -} - -/// Initialize the image render pipeline for our shader library. -fn initImagePipeline( - device: objc.Object, - library: objc.Object, - pixel_format: mtl.MTLPixelFormat, -) !Pipeline { - return try Pipeline.init(Image, .{ - .device = device, - .vertex_fn = "image_vertex", - .fragment_fn = "image_fragment", - .vertex_library = library, - .fragment_library = library, - .step_fn = .per_instance, - .attachments = &.{ - .{ - .pixel_format = pixel_format, - .blending_enabled = true, - }, - }, - }); -} - fn checkError(err_: ?*anyopaque) !void { const nserr = objc.Object.fromId(err_ orelse return); const str = @as( diff --git a/src/renderer/opengl/Pipeline.zig b/src/renderer/opengl/Pipeline.zig index 501e6124c..c3d414ff2 100644 --- a/src/renderer/opengl/Pipeline.zig +++ b/src/renderer/opengl/Pipeline.zig @@ -98,6 +98,7 @@ fn autoAttribute( const offset = @offsetOf(T, field.name); const FT = switch (@typeInfo(field.type)) { + .@"struct" => |s| s.backing_integer.?, .@"enum" => |e| e.tag_type, else => field.type, }; diff --git a/src/renderer/opengl/Texture.zig b/src/renderer/opengl/Texture.zig index 07123922f..9be2b7078 100644 --- a/src/renderer/opengl/Texture.zig +++ b/src/renderer/opengl/Texture.zig @@ -57,7 +57,6 @@ pub fn init( opts.internal_format, @intCast(width), @intCast(height), - 0, opts.format, .UnsignedByte, if (data) |d| @ptrCast(d.ptr) else null, diff --git a/src/renderer/opengl/cell.zig b/src/renderer/opengl/cell.zig deleted file mode 100644 index abdbaa0e8..000000000 --- a/src/renderer/opengl/cell.zig +++ /dev/null @@ -1,220 +0,0 @@ -const std = @import("std"); -const assert = std.debug.assert; -const Allocator = std.mem.Allocator; - -const renderer = @import("../../renderer.zig"); -const terminal = @import("../../terminal/main.zig"); -const shaderpkg = @import("shaders.zig"); - -/// The possible cell content keys that exist. -pub const Key = enum { - bg, - text, - underline, - strikethrough, - overline, - - /// Returns the GPU vertex type for this key. - pub fn CellType(self: Key) type { - return switch (self) { - .bg => shaderpkg.CellBg, - - .text, - .underline, - .strikethrough, - .overline, - => shaderpkg.CellText, - }; - } -}; - -/// A pool of ArrayLists with methods for bulk operations. -fn ArrayListPool(comptime T: type) type { - return struct { - const Self = ArrayListPool(T); - const ArrayListT = std.ArrayListUnmanaged(T); - - // An array containing the lists that belong to this pool. - lists: []ArrayListT = &[_]ArrayListT{}, - - // The pool will be initialized with empty ArrayLists. - pub fn init(alloc: Allocator, list_count: usize, initial_capacity: usize) !Self { - const self: Self = .{ - .lists = try alloc.alloc(ArrayListT, list_count), - }; - - for (self.lists) |*list| { - list.* = try ArrayListT.initCapacity(alloc, initial_capacity); - } - - return self; - } - - pub fn deinit(self: *Self, alloc: Allocator) void { - for (self.lists) |*list| { - list.deinit(alloc); - } - alloc.free(self.lists); - } - - /// Clear all lists in the pool. - pub fn reset(self: *Self) void { - for (self.lists) |*list| { - list.clearRetainingCapacity(); - } - } - }; -} - -/// The contents of all the cells in the terminal. -/// -/// The goal of this data structure is to allow for efficient row-wise -/// clearing of data from the GPU buffers, to allow for row-wise dirty -/// tracking to eliminate the overhead of rebuilding the GPU buffers -/// each frame. -/// -/// Must be initialized by resizing before calling any operations. -pub const Contents = struct { - size: renderer.GridSize = .{ .rows = 0, .columns = 0 }, - - /// Flat array containing cell background colors for the terminal grid. - /// - /// Indexed as `bg_cells[row * size.columns + col]`. - /// - /// Prefer accessing with `Contents.bgCell(row, col).*` instead - /// of directly indexing in order to avoid integer size bugs. - bg_cells: []shaderpkg.CellBg = undefined, - - /// The ArrayListPool which holds all of the foreground cells. When sized - /// with Contents.resize the individual ArrayLists are given enough room - /// that they can hold a single row with #cols glyphs, underlines, and - /// strikethroughs; however, appendAssumeCapacity MUST NOT be used since - /// it is possible to exceed this with combining glyphs that add a glyph - /// but take up no column since they combine with the previous one, as - /// well as with fonts that perform multi-substitutions for glyphs, which - /// can result in a similar situation where multiple glyphs reside in the - /// same column. - /// - /// Allocations should nevertheless be exceedingly rare since hitting the - /// initial capacity of a list would require a row filled with underlined - /// struck through characters, at least one of which is a multi-glyph - /// composite. - /// - /// Rows are indexed as Contents.fg_rows[y + 1], because the first list in - /// the pool is reserved for the cursor, which must be the first item in - /// the buffer. - /// - /// Must be initialized by calling resize on the Contents struct before - /// calling any operations. - fg_rows: ArrayListPool(shaderpkg.CellText) = .{}, - - pub fn deinit(self: *Contents, alloc: Allocator) void { - alloc.free(self.bg_cells); - self.fg_rows.deinit(alloc); - } - - /// Resize the cell contents for the given grid size. This will - /// always invalidate the entire cell contents. - pub fn resize( - self: *Contents, - alloc: Allocator, - size: renderer.GridSize, - ) !void { - self.size = size; - - const cell_count = @as(usize, size.columns) * @as(usize, size.rows); - - const bg_cells = try alloc.alloc(shaderpkg.CellBg, cell_count); - errdefer alloc.free(bg_cells); - - @memset(bg_cells, .{ 0, 0, 0, 0 }); - - // The foreground lists can hold 3 types of items: - // - Glyphs - // - Underlines - // - Strikethroughs - // So we give them an initial capacity of size.columns * 3, which will - // avoid any further allocations in the vast majority of cases. Sadly - // we can not assume capacity though, since with combining glyphs that - // form a single grapheme, and multi-substitutions in fonts, the number - // of glyphs in a row is theoretically unlimited. - // - // We have size.rows + 1 lists because index 0 is used for a special - // list containing the cursor cell which needs to be first in the buffer. - var fg_rows = try ArrayListPool(shaderpkg.CellText).init(alloc, size.rows + 1, size.columns * 3); - errdefer fg_rows.deinit(alloc); - - alloc.free(self.bg_cells); - self.fg_rows.deinit(alloc); - - self.bg_cells = bg_cells; - self.fg_rows = fg_rows; - - // We don't need 3*cols worth of cells for the cursor list, so we can - // replace it with a smaller list. This is technically a tiny bit of - // extra work but resize is not a hot function so it's worth it to not - // waste the memory. - self.fg_rows.lists[0].deinit(alloc); - self.fg_rows.lists[0] = try std.ArrayListUnmanaged(shaderpkg.CellText).initCapacity(alloc, 1); - } - - /// Reset the cell contents to an empty state without resizing. - pub fn reset(self: *Contents) void { - @memset(self.bg_cells, .{ 0, 0, 0, 0 }); - self.fg_rows.reset(); - } - - /// Set the cursor value. If the value is null then the cursor is hidden. - pub fn setCursor(self: *Contents, v: ?shaderpkg.CellText) void { - self.fg_rows.lists[0].clearRetainingCapacity(); - - if (v) |cell| { - self.fg_rows.lists[0].appendAssumeCapacity(cell); - } - } - - /// Access a background cell. Prefer this function over direct indexing - /// of `bg_cells` in order to avoid integer size bugs causing overflows. - pub inline fn bgCell(self: *Contents, row: usize, col: usize) *shaderpkg.CellBg { - return &self.bg_cells[row * self.size.columns + col]; - } - - /// Add a cell to the appropriate list. Adding the same cell twice will - /// result in duplication in the vertex buffer. The caller should clear - /// the corresponding row with Contents.clear to remove old cells first. - pub fn add( - self: *Contents, - alloc: Allocator, - comptime key: Key, - cell: key.CellType(), - ) !void { - const y = cell.grid_pos[1]; - - assert(y < self.size.rows); - - switch (key) { - .bg => comptime unreachable, - - .text, - .underline, - .strikethrough, - .overline, - // We have a special list containing the cursor cell at the start - // of our fg row pool, so we need to add 1 to the y to get the - // correct index. - => try self.fg_rows.lists[y + 1].append(alloc, cell), - } - } - - /// Clear all of the cell contents for a given row. - pub fn clear(self: *Contents, y: terminal.size.CellCountInt) void { - assert(y < self.size.rows); - - @memset(self.bg_cells[@as(usize, y) * self.size.columns ..][0..self.size.columns], .{ 0, 0, 0, 0 }); - - // We have a special list containing the cursor cell at the start - // of our fg row pool, so we need to add 1 to the y to get the - // correct index. - self.fg_rows.lists[y + 1].clearRetainingCapacity(); - } -}; diff --git a/src/renderer/opengl/image.zig b/src/renderer/opengl/image.zig deleted file mode 100644 index 77779fb8a..000000000 --- a/src/renderer/opengl/image.zig +++ /dev/null @@ -1,423 +0,0 @@ -const std = @import("std"); -const Allocator = std.mem.Allocator; -const assert = std.debug.assert; -const gl = @import("opengl"); -const wuffs = @import("wuffs"); -const OpenGL = @import("../OpenGL.zig"); -const Texture = OpenGL.Texture; - -/// Represents a single image placement on the grid. A placement is a -/// request to render an instance of an image. -pub const Placement = struct { - /// The image being rendered. This MUST be in the image map. - image_id: u32, - - /// The grid x/y where this placement is located. - x: i32, - y: i32, - z: i32, - - /// The width/height of the placed image. - width: u32, - height: u32, - - /// The offset in pixels from the top left of the cell. This is - /// clamped to the size of a cell. - cell_offset_x: u32, - cell_offset_y: u32, - - /// The source rectangle of the placement. - source_x: u32, - source_y: u32, - source_width: u32, - source_height: u32, -}; - -/// The map used for storing images. -pub const ImageMap = std.AutoHashMapUnmanaged(u32, struct { - image: Image, - transmit_time: std.time.Instant, -}); - -/// The state for a single image that is to be rendered. The image can be -/// pending upload or ready to use with a texture. -pub const Image = union(enum) { - /// The image is pending upload to the GPU. The different keys are - /// different formats since some formats aren't accepted by the GPU - /// and require conversion. - /// - /// This data is owned by this union so it must be freed once the - /// image is uploaded. - pending_gray: Pending, - pending_gray_alpha: Pending, - pending_rgb: Pending, - pending_rgba: Pending, - - /// This is the same as the pending states but there is a texture - /// already allocated that we want to replace. - replace_gray: Replace, - replace_gray_alpha: Replace, - replace_rgb: Replace, - replace_rgba: Replace, - - /// The image is uploaded and ready to be used. - ready: Texture, - - /// The image is uploaded but is scheduled to be unloaded. - unload_pending: []u8, - unload_ready: Texture, - unload_replace: struct { []u8, Texture }, - - pub const Replace = struct { - texture: Texture, - pending: Pending, - }; - - /// Pending image data that needs to be uploaded to the GPU. - pub const Pending = struct { - height: u32, - width: u32, - - /// Data is always expected to be (width * height * depth). Depth - /// is based on the union key. - data: [*]u8, - - pub fn dataSlice(self: Pending, d: u32) []u8 { - return self.data[0..self.len(d)]; - } - - pub fn len(self: Pending, d: u32) u32 { - return self.width * self.height * d; - } - }; - - pub fn deinit(self: Image, alloc: Allocator) void { - switch (self) { - .pending_gray => |p| alloc.free(p.dataSlice(1)), - .pending_gray_alpha => |p| alloc.free(p.dataSlice(2)), - .pending_rgb => |p| alloc.free(p.dataSlice(3)), - .pending_rgba => |p| alloc.free(p.dataSlice(4)), - .unload_pending => |data| alloc.free(data), - - .replace_gray => |r| { - alloc.free(r.pending.dataSlice(1)); - r.texture.deinit(); - }, - - .replace_gray_alpha => |r| { - alloc.free(r.pending.dataSlice(2)); - r.texture.deinit(); - }, - - .replace_rgb => |r| { - alloc.free(r.pending.dataSlice(3)); - r.texture.deinit(); - }, - - .replace_rgba => |r| { - alloc.free(r.pending.dataSlice(4)); - r.texture.deinit(); - }, - - .unload_replace => |r| { - alloc.free(r[0]); - r[1].deinit(); - }, - - .ready, - .unload_ready, - => |tex| tex.deinit(), - } - } - - /// Mark this image for unload whatever state it is in. - pub fn markForUnload(self: *Image) void { - self.* = switch (self.*) { - .unload_pending, - .unload_replace, - .unload_ready, - => return, - - .ready => |obj| .{ .unload_ready = obj }, - .pending_gray => |p| .{ .unload_pending = p.dataSlice(1) }, - .pending_gray_alpha => |p| .{ .unload_pending = p.dataSlice(2) }, - .pending_rgb => |p| .{ .unload_pending = p.dataSlice(3) }, - .pending_rgba => |p| .{ .unload_pending = p.dataSlice(4) }, - .replace_gray => |r| .{ .unload_replace = .{ - r.pending.dataSlice(1), r.texture, - } }, - .replace_gray_alpha => |r| .{ .unload_replace = .{ - r.pending.dataSlice(2), r.texture, - } }, - .replace_rgb => |r| .{ .unload_replace = .{ - r.pending.dataSlice(3), r.texture, - } }, - .replace_rgba => |r| .{ .unload_replace = .{ - r.pending.dataSlice(4), r.texture, - } }, - }; - } - - /// Replace the currently pending image with a new one. This will - /// attempt to update the existing texture if it is already allocated. - /// If the texture is not allocated, this will act like a new upload. - /// - /// This function only marks the image for replace. The actual logic - /// to replace is done later. - pub fn markForReplace(self: *Image, alloc: Allocator, img: Image) !void { - assert(img.pending() != null); - - // Get our existing texture. This switch statement will also handle - // scenarios where there is no existing texture and we can modify - // the self pointer directly. - const existing: Texture = switch (self.*) { - // For pending, we can free the old data and become pending ourselves. - .pending_gray => |p| { - alloc.free(p.dataSlice(1)); - self.* = img; - return; - }, - - .pending_gray_alpha => |p| { - alloc.free(p.dataSlice(2)); - self.* = img; - return; - }, - - .pending_rgb => |p| { - alloc.free(p.dataSlice(3)); - self.* = img; - return; - }, - - .pending_rgba => |p| { - alloc.free(p.dataSlice(4)); - self.* = img; - return; - }, - - // If we're marked for unload but we just have pending data, - // this behaves the same as a normal "pending": free the data, - // become new pending. - .unload_pending => |data| { - alloc.free(data); - self.* = img; - return; - }, - - .unload_replace => |r| existing: { - alloc.free(r[0]); - break :existing r[1]; - }, - - // If we were already pending a replacement, then we free our - // existing pending data and use the same texture. - .replace_gray => |r| existing: { - alloc.free(r.pending.dataSlice(1)); - break :existing r.texture; - }, - - .replace_gray_alpha => |r| existing: { - alloc.free(r.pending.dataSlice(2)); - break :existing r.texture; - }, - - .replace_rgb => |r| existing: { - alloc.free(r.pending.dataSlice(3)); - break :existing r.texture; - }, - - .replace_rgba => |r| existing: { - alloc.free(r.pending.dataSlice(4)); - break :existing r.texture; - }, - - // For both ready and unload_ready, we need to replace the - // texture. We can't do that here, so we just mark ourselves - // for replacement. - .ready, .unload_ready => |tex| tex, - }; - - // We now have an existing texture, so set the proper replace key. - self.* = switch (img) { - .pending_gray => |p| .{ .replace_gray = .{ - .texture = existing, - .pending = p, - } }, - - .pending_gray_alpha => |p| .{ .replace_gray_alpha = .{ - .texture = existing, - .pending = p, - } }, - - .pending_rgb => |p| .{ .replace_rgb = .{ - .texture = existing, - .pending = p, - } }, - - .pending_rgba => |p| .{ .replace_rgba = .{ - .texture = existing, - .pending = p, - } }, - - else => unreachable, - }; - } - - /// Returns true if this image is pending upload. - pub fn isPending(self: Image) bool { - return self.pending() != null; - } - - /// Returns true if this image is pending an unload. - pub fn isUnloading(self: Image) bool { - return switch (self) { - .unload_pending, - .unload_ready, - => true, - - .ready, - .pending_gray, - .pending_gray_alpha, - .pending_rgb, - .pending_rgba, - => false, - }; - } - - /// Converts the image data to a format that can be uploaded to the GPU. - /// If the data is already in a format that can be uploaded, this is a - /// no-op. - pub fn convert(self: *Image, alloc: Allocator) !void { - switch (self.*) { - .ready, - .unload_pending, - .unload_replace, - .unload_ready, - => unreachable, // invalid - - .pending_rgba, - .replace_rgba, - => {}, // ready - - // RGB needs to be converted to RGBA because Metal textures - // don't support RGB. - .pending_rgb => |*p| { - const data = p.dataSlice(3); - const rgba = try wuffs.swizzle.rgbToRgba(alloc, data); - alloc.free(data); - p.data = rgba.ptr; - self.* = .{ .pending_rgba = p.* }; - }, - - .replace_rgb => |*r| { - const data = r.pending.dataSlice(3); - const rgba = try wuffs.swizzle.rgbToRgba(alloc, data); - alloc.free(data); - r.pending.data = rgba.ptr; - self.* = .{ .replace_rgba = r.* }; - }, - - // Gray and Gray+Alpha need to be converted to RGBA, too. - .pending_gray => |*p| { - const data = p.dataSlice(1); - const rgba = try wuffs.swizzle.gToRgba(alloc, data); - alloc.free(data); - p.data = rgba.ptr; - self.* = .{ .pending_rgba = p.* }; - }, - - .replace_gray => |*r| { - const data = r.pending.dataSlice(2); - const rgba = try wuffs.swizzle.gToRgba(alloc, data); - alloc.free(data); - r.pending.data = rgba.ptr; - self.* = .{ .replace_rgba = r.* }; - }, - - .pending_gray_alpha => |*p| { - const data = p.dataSlice(2); - const rgba = try wuffs.swizzle.gaToRgba(alloc, data); - alloc.free(data); - p.data = rgba.ptr; - self.* = .{ .pending_rgba = p.* }; - }, - - .replace_gray_alpha => |*r| { - const data = r.pending.dataSlice(2); - const rgba = try wuffs.swizzle.gaToRgba(alloc, data); - alloc.free(data); - r.pending.data = rgba.ptr; - self.* = .{ .replace_rgba = r.* }; - }, - } - } - - /// Upload the pending image to the GPU and change the state of this - /// image to ready. - pub fn upload( - self: *Image, - alloc: Allocator, - opengl: *const OpenGL, - ) !void { - _ = opengl; - - // Convert our data if we have to - try self.convert(alloc); - - // Get our pending info - const p = self.pending().?; - - // Get our format - const formats: struct { - internal: gl.Texture.InternalFormat, - format: gl.Texture.Format, - } = switch (self.*) { - .pending_rgb, .replace_rgb => .{ .internal = .srgb, .format = .rgb }, - .pending_rgba, .replace_rgba => .{ .internal = .srgba, .format = .rgba }, - else => unreachable, - }; - - // Create our texture - const tex = try Texture.init( - .{ - .format = formats.format, - .internal_format = formats.internal, - .target = .Rectangle, - }, - @intCast(p.width), - @intCast(p.height), - p.data[0 .. p.width * p.height * self.depth()], - ); - - // Uploaded. We can now clear our data and change our state. - self.deinit(alloc); - self.* = .{ .ready = tex }; - } - - /// Our pixel depth - fn depth(self: Image) u32 { - return switch (self) { - .pending_rgb => 3, - .pending_rgba => 4, - .replace_rgb => 3, - .replace_rgba => 4, - else => unreachable, - }; - } - - /// Returns true if this image is in a pending state and requires upload. - fn pending(self: Image) ?Pending { - return switch (self) { - .pending_rgb, - .pending_rgba, - => |p| p, - - .replace_rgb, - .replace_rgba, - => |r| r.pending, - - else => null, - }; - } -}; diff --git a/src/renderer/opengl/shaders.zig b/src/renderer/opengl/shaders.zig index 7e54fd37b..0b67eaff0 100644 --- a/src/renderer/opengl/shaders.zig +++ b/src/renderer/opengl/shaders.zig @@ -7,18 +7,84 @@ const Pipeline = @import("Pipeline.zig"); const log = std.log.scoped(.opengl); +const pipeline_descs: []const struct { [:0]const u8, PipelineDescription } = + &.{ + .{ "bg_color", .{ + .vertex_fn = loadShaderCode("../shaders/glsl/full_screen.v.glsl"), + .fragment_fn = loadShaderCode("../shaders/glsl/bg_color.f.glsl"), + .blending_enabled = false, + } }, + .{ "cell_bg", .{ + .vertex_fn = loadShaderCode("../shaders/glsl/full_screen.v.glsl"), + .fragment_fn = loadShaderCode("../shaders/glsl/cell_bg.f.glsl"), + .blending_enabled = true, + } }, + .{ "cell_text", .{ + .vertex_attributes = CellText, + .vertex_fn = loadShaderCode("../shaders/glsl/cell_text.v.glsl"), + .fragment_fn = loadShaderCode("../shaders/glsl/cell_text.f.glsl"), + .step_fn = .per_instance, + .blending_enabled = true, + } }, + .{ "image", .{ + .vertex_attributes = Image, + .vertex_fn = loadShaderCode("../shaders/glsl/image.v.glsl"), + .fragment_fn = loadShaderCode("../shaders/glsl/image.f.glsl"), + .step_fn = .per_instance, + .blending_enabled = true, + } }, + .{ "bg_image", .{ + .vertex_attributes = BgImage, + .vertex_fn = loadShaderCode("../shaders/glsl/bg_image.v.glsl"), + .fragment_fn = loadShaderCode("../shaders/glsl/bg_image.f.glsl"), + .step_fn = .per_instance, + .blending_enabled = true, + } }, + }; + +/// All the comptime-known info about a pipeline, so that +/// we can define them ahead-of-time in an ergonomic way. +const PipelineDescription = struct { + vertex_attributes: ?type = null, + vertex_fn: [:0]const u8, + fragment_fn: [:0]const u8, + step_fn: Pipeline.Options.StepFunction = .per_vertex, + blending_enabled: bool = true, + + fn initPipeline(self: PipelineDescription) !Pipeline { + return try .init(self.vertex_attributes, .{ + .vertex_fn = self.vertex_fn, + .fragment_fn = self.fragment_fn, + .step_fn = self.step_fn, + .blending_enabled = self.blending_enabled, + }); + } +}; + +/// We create a type for the pipeline collection based on our desc array. +const PipelineCollection = t: { + var fields: [pipeline_descs.len]std.builtin.Type.StructField = undefined; + for (pipeline_descs, 0..) |pipeline, i| { + fields[i] = .{ + .name = pipeline[0], + .type = Pipeline, + .default_value_ptr = null, + .is_comptime = false, + .alignment = @alignOf(Pipeline), + }; + } + break :t @Type(.{ .@"struct" = .{ + .layout = .auto, + .fields = &fields, + .decls = &.{}, + .is_tuple = false, + } }); +}; + /// This contains the state for the shaders used by the Metal renderer. pub const Shaders = struct { - /// Renders cell foreground elements (text, decorations). - cell_text_pipeline: Pipeline, - - /// The cell background shader is the shader used to render the - /// background of terminal cells. - cell_bg_pipeline: Pipeline, - - /// The image shader is the shader used to render images for things - /// like the Kitty image protocol. - image_pipeline: Pipeline, + /// Collection of available render pipelines. + pipelines: PipelineCollection, /// Custom shaders to run against the final drawable texture. This /// can be used to apply a lot of effects. Each shader is run in sequence @@ -38,14 +104,20 @@ pub const Shaders = struct { alloc: Allocator, post_shaders: []const [:0]const u8, ) !Shaders { - const cell_text_pipeline = try initCellTextPipeline(); - errdefer cell_text_pipeline.deinit(); + var pipelines: PipelineCollection = undefined; - const cell_bg_pipeline = try initCellBgPipeline(); - errdefer cell_bg_pipeline.deinit(); + var initialized_pipelines: usize = 0; - const image_pipeline = try initImagePipeline(); - errdefer image_pipeline.deinit(); + errdefer inline for (pipeline_descs, 0..) |pipeline, i| { + if (i < initialized_pipelines) { + @field(pipelines, pipeline[0]).deinit(); + } + }; + + inline for (pipeline_descs) |pipeline| { + @field(pipelines, pipeline[0]) = try pipeline[1].initPipeline(); + initialized_pipelines += 1; + } const post_pipelines: []const Pipeline = initPostPipelines( alloc, @@ -63,9 +135,7 @@ pub const Shaders = struct { }; return .{ - .cell_text_pipeline = cell_text_pipeline, - .cell_bg_pipeline = cell_bg_pipeline, - .image_pipeline = image_pipeline, + .pipelines = pipelines, .post_pipelines = post_pipelines, }; } @@ -75,9 +145,9 @@ pub const Shaders = struct { self.defunct = true; // Release our primary shaders - self.cell_text_pipeline.deinit(); - self.cell_bg_pipeline.deinit(); - self.image_pipeline.deinit(); + inline for (pipeline_descs) |pipeline| { + @field(self.pipelines, pipeline[0]).deinit(); + } // Release our postprocess shaders if (self.post_pipelines.len > 0) { @@ -89,20 +159,15 @@ pub const Shaders = struct { } }; -/// Single parameter for the image shader. See shader for field details. -pub const Image = extern struct { - grid_pos: [2]f32 align(8), - cell_offset: [2]f32 align(8), - source_rect: [4]f32 align(16), - dest_size: [2]f32 align(8), -}; - -/// The uniforms that are passed to the terminal cell shader. +/// The uniforms that are passed to our shaders. pub const Uniforms = extern struct { /// The projection matrix for turning world coordinates to normalized. /// This is calculated based on the size of the screen. projection_matrix: math.Mat align(16), + /// Size of the screen (render target) in pixels. + screen_size: [2]f32 align(8), + /// Size of a single cell in pixels, unscaled. cell_size: [2]f32 align(8), @@ -165,6 +230,74 @@ pub const Uniforms = extern struct { }; }; +/// This is a single parameter for the terminal cell shader. +pub const CellText = extern struct { + glyph_pos: [2]u32 align(8) = .{ 0, 0 }, + glyph_size: [2]u32 align(8) = .{ 0, 0 }, + bearings: [2]i16 align(4) = .{ 0, 0 }, + grid_pos: [2]u16 align(4), + color: [4]u8 align(4), + mode: Mode align(4), + constraint_width: u32 align(4) = 0, + + pub const Mode = enum(u32) { + fg = 1, + fg_constrained = 2, + fg_color = 3, + cursor = 4, + fg_powerline = 5, + }; + + // test { + // // Minimizing the size of this struct is important, + // // so we test it in order to be aware of any changes. + // try std.testing.expectEqual(32, @sizeOf(CellText)); + // } +}; + +/// This is a single parameter for the cell bg shader. +pub const CellBg = [4]u8; + +/// Single parameter for the image shader. See shader for field details. +pub const Image = extern struct { + grid_pos: [2]f32 align(8), + cell_offset: [2]f32 align(8), + source_rect: [4]f32 align(16), + dest_size: [2]f32 align(8), +}; + +/// Single parameter for the bg image shader. +pub const BgImage = extern struct { + opacity: f32 align(4), + info: Info align(1), + + pub const Info = packed struct(u8) { + position: Position, + fit: Fit, + repeat: bool, + _padding: u1 = 0, + + pub const Position = enum(u4) { + tl = 0, + tc = 1, + tr = 2, + ml = 3, + mc = 4, + mr = 5, + bl = 6, + bc = 7, + br = 8, + }; + + pub const Fit = enum(u2) { + contain = 0, + cover = 1, + stretch = 2, + none = 3, + }; + }; +}; + /// Initialize our custom shader pipelines. The shaders argument is a /// set of shader source code, not file paths. fn initPostPipelines( @@ -204,60 +337,6 @@ fn initPostPipeline(data: [:0]const u8) !Pipeline { }); } -/// This is a single parameter for the terminal cell shader. -pub const CellText = extern struct { - glyph_pos: [2]u32 align(8) = .{ 0, 0 }, - glyph_size: [2]u32 align(8) = .{ 0, 0 }, - bearings: [2]i16 align(4) = .{ 0, 0 }, - grid_pos: [2]u16 align(4), - color: [4]u8 align(4), - mode: Mode align(4), - constraint_width: u32 align(4) = 0, - - pub const Mode = enum(u32) { - fg = 1, - fg_constrained = 2, - fg_color = 3, - cursor = 4, - fg_powerline = 5, - }; - - // test { - // // Minimizing the size of this struct is important, - // // so we test it in order to be aware of any changes. - // try std.testing.expectEqual(32, @sizeOf(CellText)); - // } -}; - -/// Initialize the cell render pipeline. -fn initCellTextPipeline() !Pipeline { - return try Pipeline.init(CellText, .{ - .vertex_fn = loadShaderCode("../shaders/glsl/cell_text.v.glsl"), - .fragment_fn = loadShaderCode("../shaders/glsl/cell_text.f.glsl"), - .step_fn = .per_instance, - }); -} - -/// This is a single parameter for the cell bg shader. -pub const CellBg = [4]u8; - -/// Initialize the cell background render pipeline. -fn initCellBgPipeline() !Pipeline { - return try Pipeline.init(null, .{ - .vertex_fn = loadShaderCode("../shaders/glsl/full_screen.v.glsl"), - .fragment_fn = loadShaderCode("../shaders/glsl/cell_bg.f.glsl"), - }); -} - -/// Initialize the image render pipeline. -fn initImagePipeline() !Pipeline { - return try Pipeline.init(Image, .{ - .vertex_fn = loadShaderCode("../shaders/glsl/image.v.glsl"), - .fragment_fn = loadShaderCode("../shaders/glsl/image.f.glsl"), - .step_fn = .per_instance, - }); -} - /// Load shader code from the target path, processing `#include` directives. /// /// Comptime only for now, this code is really sloppy and makes a bunch of diff --git a/src/renderer/shaders/glsl/bg_color.f.glsl b/src/renderer/shaders/glsl/bg_color.f.glsl new file mode 100644 index 000000000..616c44b89 --- /dev/null +++ b/src/renderer/shaders/glsl/bg_color.f.glsl @@ -0,0 +1,13 @@ +#include "common.glsl" + +// Must declare this output for some versions of OpenGL. +layout(location = 0) out vec4 out_FragColor; + +void main() { + bool use_linear_blending = (bools & USE_LINEAR_BLENDING) != 0; + + out_FragColor = load_color( + unpack4u8(bg_color_packed_4u8), + use_linear_blending + ); +} diff --git a/src/renderer/shaders/glsl/bg_image.f.glsl b/src/renderer/shaders/glsl/bg_image.f.glsl new file mode 100644 index 000000000..ee1195ef5 --- /dev/null +++ b/src/renderer/shaders/glsl/bg_image.f.glsl @@ -0,0 +1,63 @@ +#include "common.glsl" + +// Position the FragCoord origin to the upper left +// so as to align with our texture's directionality. +layout(origin_upper_left) in vec4 gl_FragCoord; + +layout(binding = 0) uniform sampler2D image; + +flat in vec4 bg_color; +flat in vec2 offset; +flat in vec2 scale; +flat in float opacity; +flat in uint repeat; + +layout(location = 0) out vec4 out_FragColor; + +void main() { + bool use_linear_blending = (bools & USE_LINEAR_BLENDING) != 0; + + // Our texture coordinate is based on the screen position, offset by the + // dest rect origin, and scaled by the ratio between the dest rect size + // and the original texture size, which effectively scales the original + // size of the texture to the dest rect size. + vec2 tex_coord = (gl_FragCoord.xy - offset) * scale; + + vec2 tex_size = textureSize(image, 0); + + // If we need to repeat the texture, wrap the coordinates. + if (repeat != 0) { + tex_coord = mod(mod(tex_coord, tex_size) + tex_size, tex_size); + } + + vec4 rgba; + // If we're out of bounds, we have no color, + // otherwise we sample the texture for it. + if (any(lessThan(tex_coord, vec2(0.0))) || + any(greaterThan(tex_coord, tex_size))) + { + rgba = vec4(0.0); + } else { + // We divide by the texture size to normalize for sampling. + rgba = texture(image, tex_coord / tex_size); + + if (!use_linear_blending) { + rgba = unlinearize(rgba); + } + + rgba.rgb *= rgba.a; + } + + // Multiply it by the configured opacity, but cap it at + // the value that will make it fully opaque relative to + // the background color alpha, so it isn't overexposed. + rgba *= min(opacity, 1.0 / bg_color.a); + + // Blend it on to a fully opaque version of the background color. + rgba += max(vec4(0.0), vec4(bg_color.rgb, 1.0) * vec4(1.0 - rgba.a)); + + // Multiply everything by the background color alpha. + rgba *= bg_color.a; + + out_FragColor = rgba; +} diff --git a/src/renderer/shaders/glsl/bg_image.v.glsl b/src/renderer/shaders/glsl/bg_image.v.glsl new file mode 100644 index 000000000..d55aa174a --- /dev/null +++ b/src/renderer/shaders/glsl/bg_image.v.glsl @@ -0,0 +1,145 @@ +#include "common.glsl" + +layout(binding = 0) uniform sampler2D image; + +layout(location = 0) in float in_opacity; +layout(location = 1) in uint info; + +// 4 bits of info. +const uint BG_IMAGE_POSITION = 15u; +const uint BG_IMAGE_TL = 0u; +const uint BG_IMAGE_TC = 1u; +const uint BG_IMAGE_TR = 2u; +const uint BG_IMAGE_ML = 3u; +const uint BG_IMAGE_MC = 4u; +const uint BG_IMAGE_MR = 5u; +const uint BG_IMAGE_BL = 6u; +const uint BG_IMAGE_BC = 7u; +const uint BG_IMAGE_BR = 8u; + +// 2 bits of info shifted 4. +const uint BG_IMAGE_FIT = 3u << 4; +const uint BG_IMAGE_CONTAIN = 0u << 4; +const uint BG_IMAGE_COVER = 1u << 4; +const uint BG_IMAGE_STRETCH = 2u << 4; +const uint BG_IMAGE_NO_FIT = 3u << 4; + +// 1 bit of info shifted 6. +const uint BG_IMAGE_REPEAT = 1u << 6; + +flat out vec4 bg_color; +flat out vec2 offset; +flat out vec2 scale; +flat out float opacity; +// We use a uint to pass the repeat value because +// bools aren't allowed for vertex outputs in OpenGL. +flat out uint repeat; + +void main() { + bool use_linear_blending = (bools & USE_LINEAR_BLENDING) != 0; + + vec4 position; + position.x = (gl_VertexID == 2) ? 3.0 : -1.0; + position.y = (gl_VertexID == 0) ? -3.0 : 1.0; + position.z = 1.0; + position.w = 1.0; + + // Single triangle is clipped to viewport. + // + // X <- vid == 0: (-1, -3) + // |\ + // | \ + // | \ + // |###\ + // |#+# \ `+` is (0, 0). `#`s are viewport area. + // |### \ + // X------X <- vid == 2: (3, 1) + // ^ + // vid == 1: (-1, 1) + + gl_Position = position; + + opacity = in_opacity; + + repeat = info & BG_IMAGE_REPEAT; + + vec2 screen_size = screen_size; + vec2 tex_size = textureSize(image, 0); + + vec2 dest_size = tex_size; + switch (info & BG_IMAGE_FIT) { + // For `contain` we scale by a factor that makes the image + // width match the screen width or makes the image height + // match the screen height, whichever is smaller. + case BG_IMAGE_CONTAIN: { + float scale = min(screen_size.x / tex_size.x, screen_size.y / tex_size.y); + dest_size = tex_size * scale; + } break; + + // For `cover` we scale by a factor that makes the image + // width match the screen width or makes the image height + // match the screen height, whichever is larger. + case BG_IMAGE_COVER: { + float scale = max(screen_size.x / tex_size.x, screen_size.y / tex_size.y); + dest_size = tex_size * scale; + } break; + + // For `stretch` we stretch the image to the size of + // the screen without worrying about aspect ratio. + case BG_IMAGE_STRETCH: { + dest_size = screen_size; + } break; + + // For `none` we just use the original texture size. + case BG_IMAGE_NO_FIT: { + dest_size = tex_size; + } break; + } + + vec2 start = vec2(0.0); + vec2 mid = (screen_size - dest_size) / vec2(2.0); + vec2 end = screen_size - dest_size; + + vec2 dest_offset = mid; + switch (info & BG_IMAGE_POSITION) { + case BG_IMAGE_TL: { + dest_offset = vec2(start.x, start.y); + } break; + case BG_IMAGE_TC: { + dest_offset = vec2(mid.x, start.y); + } break; + case BG_IMAGE_TR: { + dest_offset = vec2(end.x, start.y); + } break; + case BG_IMAGE_ML: { + dest_offset = vec2(start.x, mid.y); + } break; + case BG_IMAGE_MC: { + dest_offset = vec2(mid.x, mid.y); + } break; + case BG_IMAGE_MR: { + dest_offset = vec2(end.x, mid.y); + } break; + case BG_IMAGE_BL: { + dest_offset = vec2(start.x, end.y); + } break; + case BG_IMAGE_BC: { + dest_offset = vec2(mid.x, end.y); + } break; + case BG_IMAGE_BR: { + dest_offset = vec2(end.x, end.y); + } break; + } + + offset = dest_offset; + scale = tex_size / dest_size; + + // We load a fully opaque version of the bg color and combine it with + // the alpha separately, because we need these as separate values in + // the framgment shader. + uvec4 u_bg_color = unpack4u8(bg_color_packed_4u8); + bg_color = vec4(load_color( + uvec4(u_bg_color.rgb, 255), + use_linear_blending + ).rgb, float(u_bg_color.a) / 255.0); +} diff --git a/src/renderer/shaders/glsl/cell_bg.f.glsl b/src/renderer/shaders/glsl/cell_bg.f.glsl index cfd598f95..7ba6caaa6 100644 --- a/src/renderer/shaders/glsl/cell_bg.f.glsl +++ b/src/renderer/shaders/glsl/cell_bg.f.glsl @@ -15,7 +15,7 @@ vec4 cell_bg() { ivec2 grid_pos = ivec2(floor((gl_FragCoord.xy - grid_padding.wx) / cell_size)); bool use_linear_blending = (bools & USE_LINEAR_BLENDING) != 0; - vec4 bg = load_color(unpack4u8(bg_color_packed_4u8), use_linear_blending); + vec4 bg = vec4(0.0); // Clamp x position, extends edge bg colors in to padding on sides. if (grid_pos.x < 0) { diff --git a/src/renderer/shaders/glsl/cell_text.f.glsl b/src/renderer/shaders/glsl/cell_text.f.glsl index fda552424..fda6d8134 100644 --- a/src/renderer/shaders/glsl/cell_text.f.glsl +++ b/src/renderer/shaders/glsl/cell_text.f.glsl @@ -87,19 +87,19 @@ void main() { case MODE_TEXT_COLOR: { // For now, we assume that color glyphs - // are already premultiplied sRGB colors. + // are already premultiplied linear colors. vec4 color = texture(atlas_color, in_data.tex_coord); - // If we aren't doing linear blending, we can return this right away. - if (!use_linear_blending) { + // If we are doing linear blending, we can return this right away. + if (use_linear_blending) { out_FragColor = color; return; } - // Otherwise we need to linearize the color. Since the alpha is - // premultiplied, we need to divide it out before linearizing. + // Otherwise we need to unlinearize the color. Since the alpha is + // premultiplied, we need to divide it out before unlinearizing. color.rgb /= vec3(color.a); - color = linearize(color); + color = unlinearize(color); color.rgb *= vec3(color.a); out_FragColor = color; diff --git a/src/renderer/shaders/glsl/cell_text.v.glsl b/src/renderer/shaders/glsl/cell_text.v.glsl index 76ede1082..10965ddd2 100644 --- a/src/renderer/shaders/glsl/cell_text.v.glsl +++ b/src/renderer/shaders/glsl/cell_text.v.glsl @@ -139,6 +139,12 @@ void main() { unpack4u8(bg_colors[grid_pos.y * grid_size.x + grid_pos.x]), true ); + // Blend it with the global bg color + vec4 global_bg = load_color( + unpack4u8(bg_color_packed_4u8), + true + ); + out_data.bg_color += global_bg * vec4(1.0 - out_data.bg_color.a); // If we have a minimum contrast, we need to check if we need to // change the color of the text to ensure it has enough contrast diff --git a/src/renderer/shaders/glsl/common.glsl b/src/renderer/shaders/glsl/common.glsl index 0450d0c06..a0ed9f7b4 100644 --- a/src/renderer/shaders/glsl/common.glsl +++ b/src/renderer/shaders/glsl/common.glsl @@ -13,6 +13,7 @@ //----------------------------------------------------------------------------// layout(binding = 1, std140) uniform Globals { uniform mat4 projection_matrix; + uniform vec2 screen_size; uniform vec2 cell_size; uniform uint grid_size_packed_2u16; uniform vec4 grid_padding; diff --git a/src/renderer/shaders/glsl/image.f.glsl b/src/renderer/shaders/glsl/image.f.glsl index cd93cf666..4f89d7a78 100644 --- a/src/renderer/shaders/glsl/image.f.glsl +++ b/src/renderer/shaders/glsl/image.f.glsl @@ -1,6 +1,6 @@ #include "common.glsl" -layout(binding = 0) uniform sampler2DRect image; +layout(binding = 0) uniform sampler2D image; in vec2 tex_coord; diff --git a/src/renderer/shaders/glsl/image.v.glsl b/src/renderer/shaders/glsl/image.v.glsl index 55b12ed68..779fae32f 100644 --- a/src/renderer/shaders/glsl/image.v.glsl +++ b/src/renderer/shaders/glsl/image.v.glsl @@ -1,6 +1,6 @@ #include "common.glsl" -layout(binding = 0) uniform sampler2DRect image; +layout(binding = 0) uniform sampler2D image; layout(location = 0) in vec2 grid_pos; layout(location = 1) in vec2 cell_offset; @@ -32,11 +32,12 @@ void main() { // The texture coordinates start at our source x/y // and add the width/height depending on the corner. - // - // We don't need to normalize because we use pixel addressing for our sampler. tex_coord = source_rect.xy; tex_coord += source_rect.zw * corner; + // Normalize the coordinates. + tex_coord /= textureSize(image, 0); + // The position of our image starts at the top-left of the grid cell and // adds the source rect width/height components. vec2 image_pos = (cell_size * grid_pos) + cell_offset; diff --git a/src/renderer/shaders/cell.metal b/src/renderer/shaders/shaders.metal similarity index 75% rename from src/renderer/shaders/cell.metal rename to src/renderer/shaders/shaders.metal index 039c600ed..b62e0c3cf 100644 --- a/src/renderer/shaders/cell.metal +++ b/src/renderer/shaders/shaders.metal @@ -11,6 +11,7 @@ enum Padding : uint8_t { struct Uniforms { float4x4 projection_matrix; + float2 screen_size; float2 cell_size; ushort2 grid_size; float4 grid_padding; @@ -216,45 +217,245 @@ vertex FullScreenVertexOut full_screen_vertex( } //------------------------------------------------------------------- -// Cell Background Shader +// Background Color Shader //------------------------------------------------------------------- -#pragma mark - Cell BG Shader +#pragma mark - BG Color Shader -struct CellBgVertexOut { - float4 position [[position]]; - float4 bg_color; -}; - -vertex CellBgVertexOut cell_bg_vertex( - uint vid [[vertex_id]], +fragment float4 bg_color_fragment( + FullScreenVertexOut in [[stage_in]], constant Uniforms& uniforms [[buffer(1)]] ) { - CellBgVertexOut out; + return load_color( + uniforms.bg_color, + uniforms.use_display_p3, + uniforms.use_linear_blending + ); +} + +//------------------------------------------------------------------- +// Background Image Shader +//------------------------------------------------------------------- +#pragma mark - BG Image Shader + +struct BgImageVertexIn { + float opacity [[attribute(0)]]; + uint8_t info [[attribute(1)]]; +}; + +enum BgImagePosition : uint8_t { + // 4 bits of info. + BG_IMAGE_POSITION = 15u, + + BG_IMAGE_TL = 0u, + BG_IMAGE_TC = 1u, + BG_IMAGE_TR = 2u, + BG_IMAGE_ML = 3u, + BG_IMAGE_MC = 4u, + BG_IMAGE_MR = 5u, + BG_IMAGE_BL = 6u, + BG_IMAGE_BC = 7u, + BG_IMAGE_BR = 8u, +}; + +enum BgImageFit : uint8_t { + // 2 bits of info shifted 4. + BG_IMAGE_FIT = 3u << 4, + + BG_IMAGE_CONTAIN = 0u << 4, + BG_IMAGE_COVER = 1u << 4, + BG_IMAGE_STRETCH = 2u << 4, + BG_IMAGE_NO_FIT = 3u << 4, +}; + +enum BgImageRepeat : uint8_t { + // 1 bit of info shifted 6. + BG_IMAGE_REPEAT = 1u << 6, +}; + +struct BgImageVertexOut { + float4 position [[position]]; + float4 bg_color [[flat]]; + float2 offset [[flat]]; + float2 scale [[flat]]; + float opacity [[flat]]; + bool repeat [[flat]]; +}; + +vertex BgImageVertexOut bg_image_vertex( + uint vid [[vertex_id]], + BgImageVertexIn in [[stage_in]], + texture2d image [[texture(0)]], + constant Uniforms& uniforms [[buffer(1)]] +) { + BgImageVertexOut out; float4 position; position.x = (vid == 2) ? 3.0 : -1.0; position.y = (vid == 0) ? -3.0 : 1.0; position.zw = 1.0; + + // Single triangle is clipped to viewport. + // + // X <- vid == 0: (-1, -3) + // |\ + // | \ + // | \ + // |###\ + // |#+# \ `+` is (0, 0). `#`s are viewport area. + // |### \ + // X------X <- vid == 2: (3, 1) + // ^ + // vid == 1: (-1, 1) + out.position = position; - // Convert the background color to Display P3 - out.bg_color = load_color( - uniforms.bg_color, + out.opacity = in.opacity; + + out.repeat = (in.info & BG_IMAGE_REPEAT) == BG_IMAGE_REPEAT; + + float2 screen_size = uniforms.screen_size; + float2 tex_size = float2(image.get_width(), image.get_height()); + + float2 dest_size = tex_size; + switch (in.info & BG_IMAGE_FIT) { + // For `contain` we scale by a factor that makes the image + // width match the screen width or makes the image height + // match the screen height, whichever is smaller. + case BG_IMAGE_CONTAIN: { + float scale = min(screen_size.x / tex_size.x, screen_size.y / tex_size.y); + dest_size = tex_size * scale; + } break; + + // For `cover` we scale by a factor that makes the image + // width match the screen width or makes the image height + // match the screen height, whichever is larger. + case BG_IMAGE_COVER: { + float scale = max(screen_size.x / tex_size.x, screen_size.y / tex_size.y); + dest_size = tex_size * scale; + } break; + + // For `stretch` we stretch the image to the size of + // the screen without worrying about aspect ratio. + case BG_IMAGE_STRETCH: { + dest_size = screen_size; + } break; + + // For `none` we just use the original texture size. + case BG_IMAGE_NO_FIT: { + dest_size = tex_size; + } break; + } + + float2 start = float2(0.0); + float2 mid = (screen_size - dest_size) / 2; + float2 end = screen_size - dest_size; + + float2 dest_offset = mid; + switch (in.info & BG_IMAGE_POSITION) { + case BG_IMAGE_TL: { + dest_offset = float2(start.x, start.y); + } break; + case BG_IMAGE_TC: { + dest_offset = float2(mid.x, start.y); + } break; + case BG_IMAGE_TR: { + dest_offset = float2(end.x, start.y); + } break; + case BG_IMAGE_ML: { + dest_offset = float2(start.x, mid.y); + } break; + case BG_IMAGE_MC: { + dest_offset = float2(mid.x, mid.y); + } break; + case BG_IMAGE_MR: { + dest_offset = float2(end.x, mid.y); + } break; + case BG_IMAGE_BL: { + dest_offset = float2(start.x, end.y); + } break; + case BG_IMAGE_BC: { + dest_offset = float2(mid.x, end.y); + } break; + case BG_IMAGE_BR: { + dest_offset = float2(end.x, end.y); + } break; + } + + out.offset = dest_offset; + out.scale = tex_size / dest_size; + + // We load a fully opaque version of the bg color and combine it with + // the alpha separately, because we need these as separate values in + // the framgment shader. + out.bg_color = float4(load_color( + uchar4(uniforms.bg_color.rgb, 255), uniforms.use_display_p3, uniforms.use_linear_blending - ); + ).rgb, float(uniforms.bg_color.a) / 255.0); return out; } +fragment float4 bg_image_fragment( + BgImageVertexOut in [[stage_in]], + texture2d image [[texture(0)]], + constant Uniforms& uniforms [[buffer(1)]] +) { + constexpr sampler textureSampler( + coord::pixel, + address::clamp_to_zero, + filter::linear + ); + + // Our texture coordinate is based on the screen position, offset by the + // dest rect origin, and scaled by the ratio between the dest rect size + // and the original texture size, which effectively scales the original + // size of the texture to the dest rect size. + float2 tex_coord = (in.position.xy - in.offset) * in.scale; + + // If we need to repeat the texture, wrap the coordinates. + if (in.repeat) { + float2 tex_size = float2(image.get_width(), image.get_height()); + + tex_coord = fmod(fmod(tex_coord, tex_size) + tex_size, tex_size); + } + + float4 rgba = image.sample(textureSampler, tex_coord); + + if (!uniforms.use_linear_blending) { + rgba = unlinearize(rgba); + } + + // Premultiply the bg image. + rgba.rgb *= rgba.a; + + // Multiply it by the configured opacity, but cap it at + // the value that will make it fully opaque relative to + // the background color alpha, so it isn't overexposed. + rgba *= min(in.opacity, 1.0 / in.bg_color.a); + + // Blend it on to a fully opaque version of the background color. + rgba += max(float4(0.0), float4(in.bg_color.rgb, 1.0) * (1.0 - rgba.a)); + + // Multiply everything by the background color alpha. + rgba *= in.bg_color.a; + + return rgba; +} + +//------------------------------------------------------------------- +// Cell Background Shader +//------------------------------------------------------------------- +#pragma mark - Cell BG Shader + fragment float4 cell_bg_fragment( - CellBgVertexOut in [[stage_in]], + FullScreenVertexOut in [[stage_in]], constant Uniforms& uniforms [[buffer(1)]], constant uchar4 *cells [[buffer(2)]] ) { int2 grid_pos = int2(floor((in.position.xy - uniforms.grid_padding.wx) / uniforms.cell_size)); - float4 bg = in.bg_color; + float4 bg = float4(0.0); // Clamp x position, extends edge bg colors in to padding on sides. if (grid_pos.x < 0) { @@ -289,17 +490,8 @@ fragment float4 cell_bg_fragment( // Load the color for the cell. uchar4 cell_color = cells[grid_pos.y * uniforms.grid_size.x + grid_pos.x]; - // We have special case handling for when the cell color matches the bg color. - if (all(cell_color == uniforms.bg_color)) { - return bg; - } - // Convert the color and return it. // - // TODO: We may want to blend the color with the background - // color, rather than purely replacing it, this needs - // some consideration about config options though. - // // TODO: It might be a good idea to do a pass before this // to convert all of the bg colors, so we don't waste // a bunch of work converting the cell color in every @@ -462,6 +654,13 @@ vertex CellTextVertexOut cell_text_vertex( uniforms.use_display_p3, true ); + // Blend it with the global bg color + float4 global_bg = load_color( + uniforms.bg_color, + uniforms.use_display_p3, + true + ); + out.bg_color += global_bg * (1.0 - out.bg_color.a); // If we have a minimum contrast, we need to check if we need to // change the color of the text to ensure it has enough contrast @@ -566,19 +765,19 @@ fragment float4 cell_text_fragment( } case MODE_TEXT_COLOR: { - // For now, we assume that color glyphs are - // already premultiplied Display P3 colors. + // For now, we assume that color glyphs + // are already premultiplied linear colors. float4 color = textureColor.sample(textureSampler, in.tex_coord); - // If we aren't doing linear blending, we can return this right away. - if (!uniforms.use_linear_blending) { + // If we're doing linear blending, we can return this right away. + if (uniforms.use_linear_blending) { return color; } - // Otherwise we need to linearize the color. Since the alpha is - // premultiplied, we need to divide it out before linearizing. + // Otherwise we need to unlinearize the color. Since the alpha is + // premultiplied, we need to divide it out before unlinearizing. color.rgb /= color.a; - color = linearize(color); + color = unlinearize(color); color.rgb *= color.a; return color; diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 2688b03a7..079df37db 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -402,32 +402,47 @@ pub fn clonePool( }; const start_pin = pin_remap.get(ordered.tl) orelse start: { - // No start means it is outside the cloned area. We change it - // to the top-left. + // No start means it is outside the cloned area. // If we have no end pin then either // (1) our whole selection is outside the cloned area or // (2) our cloned area is within the selection if (pin_remap.get(ordered.br) == null) { - // If our tl is before the cloned area and br is after - // the cloned area then the whole screen is selected. - // This detection is somewhat more expensive so we try - // to avoid it if possible so its nested in this if. + // We check if the selection bottom right pin is above + // the cloned area or if the top left pin is below the + // cloned area, in either of these cases it means that + // the selection is fully out of bounds, so we have no + // selection in the cloned area and break out now. const clone_top = self.pages.pin(top) orelse break :sel null; - if (!sel.contains(self, clone_top)) break :sel null; + const clone_top_y = self.pages.pointFromPin( + .screen, + clone_top, + ).?.screen.y; + if (self.pages.pointFromPin( + .screen, + ordered.br.*, + ).?.screen.y < clone_top_y) break :sel null; + if (self.pages.pointFromPin( + .screen, + ordered.tl.*, + ).?.screen.y > clone_top_y) break :sel null; } - break :start try pages.trackPin(.{ .node = pages.pages.first.? }); + // We move the top pin back in bounds to the top row. + break :start try pages.trackPin(.{ + .node = pages.pages.first.?, + .x = if (sel.rectangle) ordered.tl.x else 0, + }); }; - const end_pin = pin_remap.get(ordered.br) orelse end: { - // No end means it is outside the cloned area. We change it - // to the bottom-right. - break :end try pages.trackPin(pages.pin(.{ .active = .{ - .x = pages.cols - 1, - .y = pages.rows - 1, - } }) orelse break :sel null); - }; + // If we got to this point it means that the selection is not + // fully out of bounds, so we move the bottom right pin back + // in bounds if it isn't already. + const end_pin = pin_remap.get(ordered.br) orelse try pages.trackPin(.{ + .node = pages.pages.last.?, + .x = if (sel.rectangle) ordered.br.x else pages.cols - 1, + .y = pages.pages.last.?.data.size.rows - 1, + }); break :sel .{ .bounds = .{ .tracked = .{ @@ -3053,6 +3068,29 @@ pub fn testWriteString(self: *Screen, text: []const u8) !void { } } +/// Write text that's marked as a semantic prompt. +fn testWriteSemanticString(self: *Screen, text: []const u8, semantic_prompt: Row.SemanticPrompt) !void { + // Determine the first row using the cursor position. If we know that our + // first write is going to start on the next line because of a pending + // wrap, we'll proactively start there. + const start_y = if (self.cursor.pending_wrap) self.cursor.y + 1 else self.cursor.y; + + try self.testWriteString(text); + + // Determine the last row that we actually wrote by inspecting the cursor's + // position. If we're in the first column, we haven't actually written any + // characters to it, so we end at the preceding row instead. + const end_y = if (self.cursor.x > 0) self.cursor.y else self.cursor.y - 1; + + // Mark the full range of written rows with our semantic prompt. + var y = start_y; + while (y <= end_y) { + const pin = self.pages.pin(.{ .active = .{ .y = y } }).?; + pin.rowAndCell().row.semantic_prompt = semantic_prompt; + y += 1; + } +} + test "Screen read and write" { const testing = std.testing; const alloc = testing.allocator; @@ -3671,16 +3709,11 @@ test "Screen: clearPrompt" { var s = try init(alloc, 5, 3, 0); defer s.deinit(); - const str = "1ABCD\n2EFGH\n3IJKL"; - try s.testWriteString(str); // Set one of the rows to be a prompt - { - s.cursorAbsolute(0, 1); - s.cursor.page_row.semantic_prompt = .prompt; - s.cursorAbsolute(0, 2); - s.cursor.page_row.semantic_prompt = .input; - } + try s.testWriteSemanticString("1ABCD\n", .unknown); + try s.testWriteSemanticString("2EFGH\n", .prompt); + try s.testWriteSemanticString("3IJKL", .input); s.clearPrompt(); @@ -3697,18 +3730,12 @@ test "Screen: clearPrompt continuation" { var s = try init(alloc, 5, 4, 0); defer s.deinit(); - const str = "1ABCD\n2EFGH\n3IJKL\n4MNOP"; - try s.testWriteString(str); // Set one of the rows to be a prompt followed by a continuation row - { - s.cursorAbsolute(0, 1); - s.cursor.page_row.semantic_prompt = .prompt; - s.cursorAbsolute(0, 2); - s.cursor.page_row.semantic_prompt = .prompt_continuation; - s.cursorAbsolute(0, 3); - s.cursor.page_row.semantic_prompt = .input; - } + try s.testWriteSemanticString("1ABCD\n", .unknown); + try s.testWriteSemanticString("2EFGH\n", .prompt); + try s.testWriteSemanticString("3IJKL\n", .prompt_continuation); + try s.testWriteSemanticString("4MNOP", .input); s.clearPrompt(); @@ -3719,22 +3746,17 @@ test "Screen: clearPrompt continuation" { } } -test "Screen: clearPrompt consecutive prompts" { +test "Screen: clearPrompt consecutive inputs" { const testing = std.testing; const alloc = testing.allocator; var s = try init(alloc, 5, 3, 0); defer s.deinit(); - const str = "1ABCD\n2EFGH\n3IJKL"; - try s.testWriteString(str); - // Set both rows to be prompts - { - s.cursorAbsolute(0, 1); - s.cursor.page_row.semantic_prompt = .input; - s.cursorAbsolute(0, 2); - s.cursor.page_row.semantic_prompt = .input; - } + // Set both rows to be inputs + try s.testWriteSemanticString("1ABCD\n", .unknown); + try s.testWriteSemanticString("2EFGH\n", .input); + try s.testWriteSemanticString("3IJKL", .input); s.clearPrompt(); @@ -5287,6 +5309,45 @@ test "Screen: clone contains subset of selection" { } } +test "Screen: clone contains subset of rectangle selection" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 5, 4, 1); + defer s.deinit(); + try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD"); + + // Select the full screen from x=1 to x=3 + try s.select(Selection.init( + s.pages.pin(.{ .active = .{ .x = 1, .y = 0 } }).?, + s.pages.pin(.{ .active = .{ .x = 3, .y = 3 } }).?, + true, + )); + + // Clone + var s2 = try s.clone( + alloc, + .{ .active = .{ .y = 1 } }, + .{ .active = .{ .y = 2 } }, + ); + defer s2.deinit(); + + // Our selection should remain valid and be properly clipped + // preserving the columns of the start and end points of the + // selection. + { + const sel = s2.selection.?; + try testing.expectEqual(point.Point{ .active = .{ + .x = 1, + .y = 0, + } }, s2.pages.pointFromPin(.active, sel.start()).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 3, + .y = 3, + } }, s2.pages.pointFromPin(.active, sel.end()).?); + } +} + test "Screen: clone basic" { const testing = std.testing; const alloc = testing.allocator; @@ -6003,26 +6064,24 @@ test "Screen: resize more cols no reflow preserves semantic prompt" { var s = try init(alloc, 5, 3, 0); defer s.deinit(); - const str = "1ABCD\n2EFGH\n3IJKL"; - try s.testWriteString(str); // Set one of the rows to be a prompt - { - s.cursorAbsolute(0, 1); - s.cursor.page_row.semantic_prompt = .prompt; - } + try s.testWriteSemanticString("1ABCD\n", .unknown); + try s.testWriteSemanticString("2EFGH\n", .prompt); + try s.testWriteSemanticString("3IJKL", .unknown); try s.resize(10, 3); + const expected = "1ABCD\n2EFGH\n3IJKL"; { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); + try testing.expectEqualStrings(expected, contents); } { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); + try testing.expectEqualStrings(expected, contents); } // Our one row should still be a semantic prompt, the others should not. @@ -7453,7 +7512,9 @@ test "Screen: selectLine semantic prompt boundary" { var s = try init(alloc, 5, 10, 0); defer s.deinit(); - try s.testWriteString("ABCDE\nA > "); + try s.testWriteSemanticString("ABCDE\n", .unknown); + try s.testWriteSemanticString("A ", .prompt); + try s.testWriteSemanticString("> ", .unknown); { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); @@ -7461,12 +7522,6 @@ test "Screen: selectLine semantic prompt boundary" { try testing.expectEqualStrings("ABCDE\nA \n> ", contents); } - { - const pin = s.pages.pin(.{ .screen = .{ .y = 1 } }).?; - const row = pin.rowAndCell().row; - row.semantic_prompt = .prompt; - } - // Selecting output stops at the prompt even if soft-wrapped { var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ @@ -7851,55 +7906,23 @@ test "Screen: selectOutput" { // zig fmt: off { // line number: - try s.testWriteString("output1\n"); // 0 - try s.testWriteString("output1\n"); // 1 - try s.testWriteString("prompt2\n"); // 2 - try s.testWriteString("input2\n"); // 3 - try s.testWriteString("output2output2output2output2\n"); // 4, 5, 6 due to overflow - try s.testWriteString("output2\n"); // 7 - try s.testWriteString("prompt3$ input3\n"); // 8 - try s.testWriteString("output3\n"); // 9 - try s.testWriteString("output3\n"); // 10 - try s.testWriteString("output3"); // 11 + try s.testWriteSemanticString("output1\n", .command); // 0 + try s.testWriteSemanticString("output1\n", .command); // 1 + try s.testWriteSemanticString("prompt2\n", .prompt); // 2 + try s.testWriteSemanticString("input2\n", .input); // 3 + try s.testWriteSemanticString( // + "output2output2output2output2\n", // 4, 5, 6 due to overflow + .command, // + ); // + try s.testWriteSemanticString("output2\n", .command); // 7 + try s.testWriteSemanticString("$ ", .prompt); // 8 prompt + try s.testWriteSemanticString("input3\n", .input); // 8 input + try s.testWriteSemanticString("output3\n", .command); // 9 + try s.testWriteSemanticString("output3\n", .command); // 10 + try s.testWriteSemanticString("output3", .command); // 11 } // zig fmt: on - { - const pin = s.pages.pin(.{ .screen = .{ .y = 2 } }).?; - const row = pin.rowAndCell().row; - row.semantic_prompt = .prompt; - } - { - const pin = s.pages.pin(.{ .screen = .{ .y = 3 } }).?; - const row = pin.rowAndCell().row; - row.semantic_prompt = .input; - } - { - const pin = s.pages.pin(.{ .screen = .{ .y = 4 } }).?; - const row = pin.rowAndCell().row; - row.semantic_prompt = .command; - } - { - const pin = s.pages.pin(.{ .screen = .{ .y = 5 } }).?; - const row = pin.rowAndCell().row; - row.semantic_prompt = .command; - } - { - const pin = s.pages.pin(.{ .screen = .{ .y = 6 } }).?; - const row = pin.rowAndCell().row; - row.semantic_prompt = .command; - } - { - const pin = s.pages.pin(.{ .screen = .{ .y = 8 } }).?; - const row = pin.rowAndCell().row; - row.semantic_prompt = .input; - } - { - const pin = s.pages.pin(.{ .screen = .{ .y = 9 } }).?; - const row = pin.rowAndCell().row; - row.semantic_prompt = .command; - } - // No start marker, should select from the beginning { var sel = s.selectOutput(s.pages.pin(.{ .active = .{ @@ -7945,26 +7968,17 @@ test "Screen: selectOutput" { } }, s.pages.pointFromPin(.active, sel.start()).?); try testing.expectEqual(point.Point{ .active = .{ .x = 9, - .y = 12, + .y = 11, } }, s.pages.pointFromPin(.active, sel.end()).?); } // input / prompt at y = 0, pt.y = 0 { s.deinit(); s = try init(alloc, 10, 5, 0); - try s.testWriteString("prompt1$ input1\n"); - try s.testWriteString("output1\n"); - try s.testWriteString("prompt2\n"); - { - const pin = s.pages.pin(.{ .screen = .{ .y = 0 } }).?; - const row = pin.rowAndCell().row; - row.semantic_prompt = .input; - } - { - const pin = s.pages.pin(.{ .screen = .{ .y = 1 } }).?; - const row = pin.rowAndCell().row; - row.semantic_prompt = .command; - } + try s.testWriteSemanticString("$ ", .prompt); + try s.testWriteSemanticString("input1\n", .input); + try s.testWriteSemanticString("output1\n", .command); + try s.testWriteSemanticString("prompt2\n", .prompt); try testing.expect(s.selectOutput(s.pages.pin(.{ .active = .{ .x = 2, .y = 0, @@ -7981,46 +7995,21 @@ test "Screen: selectPrompt basics" { // zig fmt: off { - // line number: - try s.testWriteString("output1\n"); // 0 - try s.testWriteString("output1\n"); // 1 - try s.testWriteString("prompt2\n"); // 2 - try s.testWriteString("input2\n"); // 3 - try s.testWriteString("output2\n"); // 4 - try s.testWriteString("output2\n"); // 5 - try s.testWriteString("prompt3$ input3\n"); // 6 - try s.testWriteString("output3\n"); // 7 - try s.testWriteString("output3\n"); // 8 - try s.testWriteString("output3"); // 9 + // line number: + try s.testWriteSemanticString("output1\n", .command); // 0 + try s.testWriteSemanticString("output1\n", .command); // 1 + try s.testWriteSemanticString("prompt2\n", .prompt); // 2 + try s.testWriteSemanticString("input2\n", .input); // 3 + try s.testWriteSemanticString("output2\n", .command); // 4 + try s.testWriteSemanticString("output2\n", .command); // 5 + try s.testWriteSemanticString("$ ", .prompt); // 6 prompt + try s.testWriteSemanticString("input3\n", .input); // 6 input + try s.testWriteSemanticString("output3\n", .command); // 7 + try s.testWriteSemanticString("output3\n", .command); // 8 + try s.testWriteSemanticString("output3", .command); // 9 } // zig fmt: on - { - const pin = s.pages.pin(.{ .screen = .{ .y = 2 } }).?; - const row = pin.rowAndCell().row; - row.semantic_prompt = .prompt; - } - { - const pin = s.pages.pin(.{ .screen = .{ .y = 3 } }).?; - const row = pin.rowAndCell().row; - row.semantic_prompt = .input; - } - { - const pin = s.pages.pin(.{ .screen = .{ .y = 4 } }).?; - const row = pin.rowAndCell().row; - row.semantic_prompt = .command; - } - { - const pin = s.pages.pin(.{ .screen = .{ .y = 6 } }).?; - const row = pin.rowAndCell().row; - row.semantic_prompt = .input; - } - { - const pin = s.pages.pin(.{ .screen = .{ .y = 7 } }).?; - const row = pin.rowAndCell().row; - row.semantic_prompt = .command; - } - // Not at a prompt { const sel = s.selectPrompt(s.pages.pin(.{ .active = .{ @@ -8081,30 +8070,14 @@ test "Screen: selectPrompt prompt at start" { // zig fmt: off { - // line number: - try s.testWriteString("prompt1\n"); // 0 - try s.testWriteString("input1\n"); // 1 - try s.testWriteString("output2\n"); // 2 - try s.testWriteString("output2\n"); // 3 + // line number: + try s.testWriteSemanticString("prompt1\n", .prompt); // 0 + try s.testWriteSemanticString("input1\n", .input); // 1 + try s.testWriteSemanticString("output2\n", .command); // 2 + try s.testWriteSemanticString("output2\n", .command); // 3 } // zig fmt: on - { - const pin = s.pages.pin(.{ .screen = .{ .y = 0 } }).?; - const row = pin.rowAndCell().row; - row.semantic_prompt = .prompt; - } - { - const pin = s.pages.pin(.{ .screen = .{ .y = 1 } }).?; - const row = pin.rowAndCell().row; - row.semantic_prompt = .input; - } - { - const pin = s.pages.pin(.{ .screen = .{ .y = 2 } }).?; - const row = pin.rowAndCell().row; - row.semantic_prompt = .command; - } - // Not at a prompt { const sel = s.selectPrompt(s.pages.pin(.{ .active = .{ @@ -8141,25 +8114,14 @@ test "Screen: selectPrompt prompt at end" { // zig fmt: off { - // line number: - try s.testWriteString("output2\n"); // 0 - try s.testWriteString("output2\n"); // 1 - try s.testWriteString("prompt1\n"); // 2 - try s.testWriteString("input1\n"); // 3 + // line number: + try s.testWriteSemanticString("output2\n", .command); // 0 + try s.testWriteSemanticString("output2\n", .command); // 1 + try s.testWriteSemanticString("prompt1\n", .prompt); // 2 + try s.testWriteSemanticString("input1\n", .input); // 3 } // zig fmt: on - { - const pin = s.pages.pin(.{ .screen = .{ .y = 2 } }).?; - const row = pin.rowAndCell().row; - row.semantic_prompt = .prompt; - } - { - const pin = s.pages.pin(.{ .screen = .{ .y = 3 } }).?; - const row = pin.rowAndCell().row; - row.semantic_prompt = .input; - } - // Not at a prompt { const sel = s.selectPrompt(s.pages.pin(.{ .active = .{ @@ -8196,46 +8158,21 @@ test "Screen: promptPath" { // zig fmt: off { - // line number: - try s.testWriteString("output1\n"); // 0 - try s.testWriteString("output1\n"); // 1 - try s.testWriteString("prompt2\n"); // 2 - try s.testWriteString("input2\n"); // 3 - try s.testWriteString("output2\n"); // 4 - try s.testWriteString("output2\n"); // 5 - try s.testWriteString("prompt3$ input3\n"); // 6 - try s.testWriteString("output3\n"); // 7 - try s.testWriteString("output3\n"); // 8 - try s.testWriteString("output3"); // 9 + // line number: + try s.testWriteSemanticString("output1\n", .command); // 0 + try s.testWriteSemanticString("output1\n", .command); // 1 + try s.testWriteSemanticString("prompt2\n", .prompt); // 2 + try s.testWriteSemanticString("input2\n", .input); // 3 + try s.testWriteSemanticString("output2\n", .command); // 4 + try s.testWriteSemanticString("output2\n", .command); // 5 + try s.testWriteSemanticString("$ ", .prompt); // 6 prompt + try s.testWriteSemanticString("input3\n", .input); // 6 input + try s.testWriteSemanticString("output3\n", .command); // 7 + try s.testWriteSemanticString("output3\n", .command); // 8 + try s.testWriteSemanticString("output3", .command); // 9 } // zig fmt: on - { - const pin = s.pages.pin(.{ .screen = .{ .y = 2 } }).?; - const row = pin.rowAndCell().row; - row.semantic_prompt = .prompt; - } - { - const pin = s.pages.pin(.{ .screen = .{ .y = 3 } }).?; - const row = pin.rowAndCell().row; - row.semantic_prompt = .input; - } - { - const pin = s.pages.pin(.{ .screen = .{ .y = 4 } }).?; - const row = pin.rowAndCell().row; - row.semantic_prompt = .command; - } - { - const pin = s.pages.pin(.{ .screen = .{ .y = 6 } }).?; - const row = pin.rowAndCell().row; - row.semantic_prompt = .input; - } - { - const pin = s.pages.pin(.{ .screen = .{ .y = 7 } }).?; - const row = pin.rowAndCell().row; - row.semantic_prompt = .command; - } - // From is not in the prompt { const path = s.promptPath( diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index be7a58f9b..dd7207f6d 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -124,6 +124,9 @@ flags: packed struct { /// to true based on termios state. password_input: bool = false, + /// True if the terminal should perform selection scrolling. + selection_scroll: bool = false, + /// Dirty flags for the renderer. dirty: Dirty = .{}, } = .{}, diff --git a/src/terminal/style.zig b/src/terminal/style.zig index f35a4e1f7..865e15f64 100644 --- a/src/terminal/style.zig +++ b/src/terminal/style.zig @@ -8,9 +8,6 @@ const Offset = size.Offset; const OffsetBuf = size.OffsetBuf; const RefCountedSet = @import("ref_counted_set.zig").RefCountedSet; -const XxHash3 = std.hash.XxHash3; -const autoHash = std.hash.autoHash; - /// The unique identifier for a style. This is at most the number of cells /// that can fit into a terminal page. pub const Id = size.CellCountInt; @@ -313,12 +310,15 @@ pub const Style = struct { pub fn hash(self: *const Style) u64 { const packed_style = PackedStyle.fromStyle(self.*); - return XxHash3.hash(0, std.mem.asBytes(&packed_style)); + return std.hash.XxHash3.hash(0, std.mem.asBytes(&packed_style)); } comptime { assert(@sizeOf(PackedStyle) == 16); assert(std.meta.hasUniqueRepresentation(PackedStyle)); + for (@typeInfo(PackedStyle.Data).@"union".fields) |field| { + assert(@bitSizeOf(field.type) == @bitSizeOf(PackedStyle.Data)); + } } }; diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index aed7cefb6..b8f838cf9 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -10,6 +10,7 @@ const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const posix = std.posix; const xev = @import("../global.zig").xev; +const apprt = @import("../apprt.zig"); const build_config = @import("../build_config.zig"); const configpkg = @import("../config.zig"); const crash = @import("../crash/main.zig"); @@ -153,8 +154,6 @@ pub fn threadEnter( // Setup our threadata backend state to be our own td.backend = .{ .exec = .{ .start = process_start, - .abnormal_runtime_threshold_ms = io.config.abnormal_runtime_threshold_ms, - .wait_after_command = io.config.wait_after_command, .write_stream = stream, .process = process, .read_thread = read_thread, @@ -273,83 +272,6 @@ pub fn resize( return try self.subprocess.resize(grid_size, screen_size); } -/// Called when the child process exited abnormally but before the surface -/// is notified. -pub fn childExitedAbnormally( - self: *Exec, - gpa: Allocator, - t: *terminal.Terminal, - exit_code: u32, - runtime_ms: u64, -) !void { - var arena = ArenaAllocator.init(gpa); - defer arena.deinit(); - const alloc = arena.allocator(); - - // Build up our command for the error message - const command = try std.mem.join(alloc, " ", self.subprocess.args); - const runtime_str = try std.fmt.allocPrint(alloc, "{d} ms", .{runtime_ms}); - - // No matter what move the cursor back to the column 0. - t.carriageReturn(); - - // Reset styles - try t.setAttribute(.{ .unset = {} }); - - // If there is data in the viewport, we want to scroll down - // a little bit and write a horizontal rule before writing - // our message. This lets the use see the error message the - // command may have output. - const viewport_str = try t.plainString(alloc); - if (viewport_str.len > 0) { - try t.linefeed(); - for (0..t.cols) |_| try t.print(0x2501); - t.carriageReturn(); - try t.linefeed(); - try t.linefeed(); - } - - // Output our error message - try t.setAttribute(.{ .@"8_fg" = .bright_red }); - try t.setAttribute(.{ .bold = {} }); - try t.printString("Ghostty failed to launch the requested command:"); - try t.setAttribute(.{ .unset = {} }); - - t.carriageReturn(); - try t.linefeed(); - try t.linefeed(); - try t.printString(command); - try t.setAttribute(.{ .unset = {} }); - - t.carriageReturn(); - try t.linefeed(); - try t.linefeed(); - try t.printString("Runtime: "); - try t.setAttribute(.{ .@"8_fg" = .red }); - try t.printString(runtime_str); - try t.setAttribute(.{ .unset = {} }); - - // We don't print this on macOS because the exit code is always 0 - // due to the way we launch the process. - if (comptime !builtin.target.os.tag.isDarwin()) { - const exit_code_str = try std.fmt.allocPrint(alloc, "{d}", .{exit_code}); - t.carriageReturn(); - try t.linefeed(); - try t.printString("Exit Code: "); - try t.setAttribute(.{ .@"8_fg" = .red }); - try t.printString(exit_code_str); - try t.setAttribute(.{ .unset = {} }); - } - - t.carriageReturn(); - try t.linefeed(); - try t.linefeed(); - try t.printString("Press any key to close the window."); - - // Hide the cursor - t.modes.set(.cursor_visible, false); -} - /// This outputs an error message when exec failed and we are the /// child process. This returns so the caller should probably exit /// after calling this. @@ -386,63 +308,13 @@ fn processExitCommon(td: *termio.Termio.ThreadData, exit_code: u32) void { .{ exit_code, runtime_ms orelse 0 }, ); - // If our runtime was below some threshold then we assume that this - // was an abnormal exit and we show an error message. - if (runtime_ms) |runtime| runtime: { - // On macOS, our exit code detection doesn't work, possibly - // because of our `login` wrapper. More investigation required. - if (comptime !builtin.target.os.tag.isDarwin()) { - // If our exit code is zero, then the command was successful - // and we don't ever consider it abnormal. - if (exit_code == 0) break :runtime; - } - - // Our runtime always has to be under the threshold to be - // considered abnormal. This is because a user can always - // manually do something like `exit 1` in their shell to - // force the exit code to be non-zero. We only want to detect - // abnormal exits that happen so quickly the user can't react. - if (runtime > execdata.abnormal_runtime_threshold_ms) break :runtime; - log.warn("abnormal process exit detected, showing error message", .{}); - - // Notify our main writer thread which has access to more - // information so it can show a better error message. - td.mailbox.send(.{ - .child_exited_abnormally = .{ - .exit_code = exit_code, - .runtime_ms = runtime, - }, - }, null); - td.mailbox.notify(); - - return; - } - - // We output a message so that the user knows whats going on and - // doesn't think their terminal just froze. We show this unconditionally - // on close even if `wait_after_command` is false and the surface closes - // immediately because if a user does an `undo` to restore a closed - // surface then they will see this message and know the process has - // completed. - terminal: { - td.renderer_state.mutex.lock(); - defer td.renderer_state.mutex.unlock(); - const t = td.renderer_state.terminal; - t.carriageReturn(); - t.linefeed() catch break :terminal; - t.printString("Process exited. Press any key to close the terminal.") catch - break :terminal; - t.modes.set(.cursor_visible, false); - } - - // If we're purposely waiting then we just return since the process - // exited flag is set to true. This allows the terminal window to remain - // open. - if (execdata.wait_after_command) return; - - // Notify our surface we want to close + // We always notify the surface immediately that the child has + // exited and some metadata about the exit. _ = td.surface_mailbox.push(.{ - .child_exited = {}, + .child_exited = .{ + .exit_code = exit_code, + .runtime_ms = runtime_ms orelse 0, + }, }, .{ .forever = {} }); } @@ -563,14 +435,8 @@ pub fn queueWrite( _ = self; const exec = &td.backend.exec; - // If our process is exited then we send our surface a message - // about it but we don't queue any more writes. - if (exec.exited) { - _ = td.surface_mailbox.push(.{ - .child_exited = {}, - }, .{ .forever = {} }); - return; - } + // If our process is exited then we don't send any more writes. + if (exec.exited) return; // We go through and chunk the data if necessary to fit into // our cached buffers that we can queue to the stream. @@ -658,17 +524,6 @@ pub const ThreadData = struct { start: std.time.Instant, exited: bool = false, - /// The number of milliseconds below which we consider a process - /// exit to be abnormal. This is used to show an error message - /// when the process exits too quickly. - abnormal_runtime_threshold_ms: u32, - - /// If true, do not immediately send a child exited message to the - /// surface to close the surface when the command exits. If this is - /// false we'll show a process exited message and wait for user input - /// to close the surface. - wait_after_command: bool, - /// The data stream is the main IO for the pty. write_stream: xev.Stream, diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index c474d55bb..8aaa87011 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -168,8 +168,7 @@ pub const DerivedConfig = struct { foreground: configpkg.Config.Color, background: configpkg.Config.Color, osc_color_report_format: configpkg.Config.OSCColorReportFormat, - abnormal_runtime_threshold_ms: u32, - wait_after_command: bool, + clipboard_write: configpkg.ClipboardAccess, enquiry_response: []const u8, pub fn init( @@ -190,8 +189,7 @@ pub const DerivedConfig = struct { .foreground = config.foreground, .background = config.background, .osc_color_report_format = config.@"osc-color-report-format", - .abnormal_runtime_threshold_ms = config.@"abnormal-command-exit-runtime", - .wait_after_command = config.@"wait-after-command", + .clipboard_write = config.@"clipboard-write", .enquiry_response = try alloc.dupe(u8, config.@"enquiry-response"), // This has to be last so that we copy AFTER the arena allocations @@ -282,6 +280,7 @@ pub fn init(self: *Termio, alloc: Allocator, opts: termio.Options) !void { .size = &self.size, .terminal = &self.terminal, .osc_color_report_format = opts.config.osc_color_report_format, + .clipboard_write = opts.config.clipboard_write, .enquiry_response = opts.config.enquiry_response, .default_foreground_color = opts.config.foreground.toTerminalRGB(), .default_background_color = opts.config.background.toTerminalRGB(), @@ -660,15 +659,6 @@ pub fn jumpToPrompt(self: *Termio, delta: isize) !void { try self.renderer_wakeup.notify(); } -/// Called when the child process exited abnormally but before -/// the surface is notified. -pub fn childExitedAbnormally(self: *Termio, exit_code: u32, runtime_ms: u64) !void { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); - const t = self.renderer_state.terminal; - try self.backend.childExitedAbnormally(self.alloc, t, exit_code, runtime_ms); -} - /// Called when focus is gained or lost (when focus events are enabled) pub fn focusGained(self: *Termio, td: *ThreadData, focused: bool) !void { self.renderer_state.mutex.lock(); diff --git a/src/termio/Thread.zig b/src/termio/Thread.zig index 58a04f5a7..7773ea7cd 100644 --- a/src/termio/Thread.zig +++ b/src/termio/Thread.zig @@ -37,6 +37,9 @@ const Coalesce = struct { /// if the running program hasn't already. const sync_reset_ms = 1000; +/// The number of milliseconds between each movement during selection scrolling. +const selection_scroll_ms = 15; + /// Allocator used for some state alloc: std.mem.Allocator, @@ -53,6 +56,11 @@ wakeup_c: xev.Completion = .{}, stop: xev.Async, stop_c: xev.Completion = .{}, +/// This is used for timer-based selection scrolling. +scroll: xev.Timer, +scroll_c: xev.Completion = .{}, +scroll_active: bool = false, + /// This is used to coalesce resize events. coalesce: xev.Timer, coalesce_c: xev.Completion = .{}, @@ -92,6 +100,10 @@ pub fn init( var stop_h = try xev.Async.init(); errdefer stop_h.deinit(); + // This timer is used for selection scrolling. + var scroll_h = try xev.Timer.init(); + errdefer scroll_h.deinit(); + // This timer is used to coalesce resize events. var coalesce_h = try xev.Timer.init(); errdefer coalesce_h.deinit(); @@ -104,6 +116,7 @@ pub fn init( .alloc = alloc, .loop = loop, .stop = stop_h, + .scroll = scroll_h, .coalesce = coalesce_h, .sync_reset = sync_reset_h, }; @@ -112,6 +125,7 @@ pub fn init( /// Clean up the thread. This is only safe to call once the thread /// completes executing; the caller must join prior to this. pub fn deinit(self: *Thread) void { + self.scroll.deinit(); self.coalesce.deinit(); self.sync_reset.deinit(); self.stop.deinit(); @@ -308,10 +322,16 @@ fn drainMailbox( .size_report => |v| try io.sizeReport(data, v), .clear_screen => |v| try io.clearScreen(data, v.history), .scroll_viewport => |v| try io.scrollViewport(v), + .selection_scroll => |v| { + if (v) { + self.startScrollTimer(cb); + } else { + self.stopScrollTimer(); + } + }, .jump_to_prompt => |v| try io.jumpToPrompt(v), .start_synchronized_output => self.startSynchronizedOutput(cb), .linefeed_mode => |v| self.flags.linefeed_mode = v, - .child_exited_abnormally => |v| try io.childExitedAbnormally(v.exit_code, v.runtime_ms), .focused => |v| try io.focusGained(data, v), .write_small => |v| try io.queueWrite( data, @@ -447,3 +467,57 @@ fn stopCallback( cb_.?.self.loop.stop(); return .disarm; } + +fn startScrollTimer(self: *Thread, cb: *CallbackData) void { + self.scroll_active = true; + + // Start the timer which loops + self.scroll.run( + &self.loop, + &self.scroll_c, + selection_scroll_ms, + CallbackData, + cb, + selectionScrollCallback, + ); +} + +fn stopScrollTimer(self: *Thread) void { + // This will stop the scrolling on the next iteration. + self.scroll_active = false; +} + +fn selectionScrollCallback( + cb_: ?*CallbackData, + _: *xev.Loop, + _: *xev.Completion, + r: xev.Timer.RunError!void, +) xev.CallbackAction { + _ = r catch |err| switch (err) { + error.Canceled => {}, + else => { + log.warn("error during selection scroll callback err={}", .{err}); + return .disarm; + }, + }; + + const cb = cb_ orelse return .disarm; + const self = cb.self; + + // Send the tick to the main surface + _ = cb.io.surface_mailbox.push( + .{ .selection_scroll_tick = self.scroll_active }, + .{ .instant = {} }, + ); + + if (self.scroll_active) self.scroll.run( + &self.loop, + &self.scroll_c, + selection_scroll_ms, + CallbackData, + cb, + selectionScrollCallback, + ); + + return .disarm; +} diff --git a/src/termio/backend.zig b/src/termio/backend.zig index 46ed3431c..280fcbde1 100644 --- a/src/termio/backend.zig +++ b/src/termio/backend.zig @@ -122,11 +122,7 @@ pub const ThreadData = union(Kind) { } pub fn changeConfig(self: *ThreadData, config: *termio.DerivedConfig) void { - switch (self.*) { - .exec => |*exec| { - exec.abnormal_runtime_threshold_ms = config.abnormal_runtime_threshold_ms; - exec.wait_after_command = config.wait_after_command; - }, - } + _ = self; + _ = config; } }; diff --git a/src/termio/message.zig b/src/termio/message.zig index 42767e109..ee6dbcc0f 100644 --- a/src/termio/message.zig +++ b/src/termio/message.zig @@ -1,6 +1,7 @@ const std = @import("std"); const assert = std.debug.assert; const Allocator = std.mem.Allocator; +const apprt = @import("../apprt.zig"); const renderer = @import("../renderer.zig"); const terminal = @import("../terminal/main.zig"); const termio = @import("../termio.zig"); @@ -47,6 +48,12 @@ pub const Message = union(enum) { /// Scroll the viewport scroll_viewport: terminal.Terminal.ScrollViewport, + /// Selection scrolling. If this is set to true then the termio + /// thread starts a timer that will trigger a `selection_scroll_tick` + /// message back to the surface. This ping/pong is because the + /// surface thread doesn't have access to an event loop from libghostty. + selection_scroll: bool, + /// Jump forward/backward n prompts. jump_to_prompt: isize, @@ -58,15 +65,6 @@ pub const Message = union(enum) { /// Enable or disable linefeed mode (mode 20). linefeed_mode: bool, - /// The child exited abnormally. The termio state is marked - /// as process exited but the surface hasn't been notified to - /// close because termio can use this to update the terminal - /// with an error message. - child_exited_abnormally: struct { - exit_code: u32, - runtime_ms: u64, - }, - /// The surface gained or lost focus. focused: bool, diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 2069a8ff2..1b4fdd3aa 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -74,6 +74,9 @@ pub const StreamHandler = struct { /// The color reporting format for OSC requests. osc_color_report_format: configpkg.Config.OSCColorReportFormat, + /// The clipboard write access configuration. + clipboard_write: configpkg.ClipboardAccess, + //--------------------------------------------------------------- // Internal state @@ -112,6 +115,7 @@ pub const StreamHandler = struct { /// Change the configuration for this handler. pub fn changeConfig(self: *StreamHandler, config: *termio.DerivedConfig) void { self.osc_color_report_format = config.osc_color_report_format; + self.clipboard_write = config.clipboard_write; self.enquiry_response = config.enquiry_response; self.default_foreground_color = config.foreground.toTerminalRGB(); self.default_background_color = config.background.toTerminalRGB(); @@ -723,7 +727,13 @@ pub const StreamHandler = struct { // a 420 because we don't support DCS sequences. switch (req) { .primary => self.messageWriter(.{ - .write_stable = "\x1B[?62;22c", + // 62 = Level 2 conformance + // 22 = Color text + // 52 = Clipboard access + .write_stable = if (self.clipboard_write != .deny) + "\x1B[?62;22;52c" + else + "\x1B[?62;22c", }), .secondary => self.messageWriter(.{ @@ -1081,7 +1091,7 @@ pub const StreamHandler = struct { return; } - const uri = std.Uri.parse(url) catch |e| { + const uri: std.Uri = internal_os.hostname.parseUrl(url) catch |e| { log.warn("invalid url in OSC 7: {}", .{e}); return; };