summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.github/workflows/nix.yml2
-rw-r--r--.github/workflows/release-pr.yml8
-rw-r--r--.github/workflows/release-tag.yml8
-rw-r--r--.github/workflows/release-tip.yml22
-rw-r--r--.github/workflows/test.yml99
-rw-r--r--.github/workflows/update-colorschemes.yml2
-rw-r--r--.prettierignore3
-rw-r--r--CODEOWNERS4
-rw-r--r--LICENSE2
-rw-r--r--README.md22
-rw-r--r--TODO.md21
-rw-r--r--build.zig.zon8
-rw-r--r--build.zig.zon.json12
-rw-r--r--build.zig.zon.nix12
-rw-r--r--build.zig.zon.txt4
-rw-r--r--dist/linux/app.desktop.in (renamed from dist/linux/app.desktop)11
-rw-r--r--dist/linux/com.mitchellh.ghostty.metainfo.xml.in (renamed from dist/linux/com.mitchellh.ghostty.metainfo.xml)6
-rw-r--r--dist/linux/dbus.service.flatpak.in3
-rw-r--r--dist/linux/dbus.service.in4
-rw-r--r--dist/linux/systemd.service.in7
-rw-r--r--dist/macos/Ghostty.icnsbin382978 -> 0 bytes
-rw-r--r--dist/macos/Info.plist17
-rw-r--r--flake.lock54
-rw-r--r--flake.nix42
-rw-r--r--flatpak/com.mitchellh.ghostty-debug.yml (renamed from flatpak/com.mitchellh.ghostty.Devel.yml)6
-rw-r--r--flatpak/zig-packages.json12
-rw-r--r--images/Ghostty.icon/Assets/Ghostty.pngbin0 -> 106126 bytes
-rw-r--r--images/Ghostty.icon/Assets/Inner Bevel 6px.pngbin0 -> 435672 bytes
-rw-r--r--images/Ghostty.icon/Assets/Screen Effects.pngbin0 -> 92547 bytes
-rw-r--r--images/Ghostty.icon/Assets/Screen.pngbin0 -> 143481 bytes
-rw-r--r--images/Ghostty.icon/Assets/gloss.pngbin0 -> 3353 bytes
-rw-r--r--images/Ghostty.icon/icon.json170
-rw-r--r--images/icons/icon_1024.pngbin464853 -> 2365230 bytes
-rw-r--r--images/icons/icon_1024@2x.pngbin0 -> 2365230 bytes
-rw-r--r--images/icons/icon_128.pngbin15177 -> 15089 bytes
-rw-r--r--images/icons/icon_256.pngbin68189 -> 237699 bytes
-rw-r--r--images/icons/icon_256@2x.pngbin221047 -> 237699 bytes
-rw-r--r--images/icons/icon_512.pngbin221047 -> 667563 bytes
-rw-r--r--images/icons/icon_512@2x.pngbin0 -> 667563 bytes
-rw-r--r--include/ghostty.h60
-rw-r--r--macos/Assets.xcassets/AppIcon.appiconset/Contents.json74
-rw-r--r--macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-1024px 1.pngbin464853 -> 0 bytes
-rw-r--r--macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-1024px.pngbin464853 -> 0 bytes
-rw-r--r--macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-128px-128pt@1x.pngbin15177 -> 0 bytes
-rw-r--r--macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-16px-16pt@1x.pngbin666 -> 0 bytes
-rw-r--r--macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-256px-128pt@2x 1.pngbin68177 -> 0 bytes
-rw-r--r--macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-256px-128pt@2x.pngbin68177 -> 0 bytes
-rw-r--r--macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-32px-16pt@2x.pngbin1562 -> 0 bytes
-rw-r--r--macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-32px-32pt@1x.pngbin1564 -> 0 bytes
-rw-r--r--macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-512px-256pt@2x.pngbin221047 -> 0 bytes
-rw-r--r--macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-512px.pngbin220725 -> 0 bytes
-rw-r--r--macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-64px-32pt@2x.pngbin4485 -> 0 bytes
-rw-r--r--macos/Ghostty.xcodeproj/project.pbxproj270
-rw-r--r--macos/Sources/App/macOS/AppDelegate.swift194
-rw-r--r--macos/Sources/App/macOS/MainMenu.xib28
-rw-r--r--macos/Sources/Features/App Intents/CloseTerminalIntent.swift35
-rw-r--r--macos/Sources/Features/App Intents/CommandPaletteIntent.swift38
-rw-r--r--macos/Sources/Features/App Intents/Entities/CommandEntity.swift128
-rw-r--r--macos/Sources/Features/App Intents/Entities/TerminalEntity.swift139
-rw-r--r--macos/Sources/Features/App Intents/GetTerminalDetailsIntent.swift69
-rw-r--r--macos/Sources/Features/App Intents/GhosttyIntentError.swift13
-rw-r--r--macos/Sources/Features/App Intents/InputIntent.swift317
-rw-r--r--macos/Sources/Features/App Intents/IntentPermission.swift57
-rw-r--r--macos/Sources/Features/App Intents/KeybindIntent.swift35
-rw-r--r--macos/Sources/Features/App Intents/NewTerminalIntent.swift168
-rw-r--r--macos/Sources/Features/App Intents/QuickTerminalIntent.swift32
-rw-r--r--macos/Sources/Features/Colorized Ghostty Icon/ColorizedGhosttyIconImage.swift14
-rw-r--r--macos/Sources/Features/Command Palette/TerminalCommandPalette.swift37
-rw-r--r--macos/Sources/Features/Global Keybinds/GlobalEventTap.swift7
-rw-r--r--macos/Sources/Features/QuickTerminal/QuickTerminalController.swift97
-rw-r--r--macos/Sources/Features/Services/ServiceProvider.swift40
-rw-r--r--macos/Sources/Features/Splits/SplitTree.swift1284
-rw-r--r--macos/Sources/Features/Splits/SplitView.Divider.swift (renamed from macos/Sources/Helpers/SplitView/SplitView.Divider.swift)35
-rw-r--r--macos/Sources/Features/Splits/SplitView.swift (renamed from macos/Sources/Helpers/SplitView/SplitView.swift)82
-rw-r--r--macos/Sources/Features/Splits/TerminalSplitTreeView.swift62
-rw-r--r--macos/Sources/Features/Terminal/BaseTerminalController.swift514
-rw-r--r--macos/Sources/Features/Terminal/TerminalController.swift930
-rw-r--r--macos/Sources/Features/Terminal/TerminalManager.swift372
-rw-r--r--macos/Sources/Features/Terminal/TerminalRestorable.swift27
-rw-r--r--macos/Sources/Features/Terminal/TerminalToolbar.swift120
-rw-r--r--macos/Sources/Features/Terminal/TerminalView.swift30
-rw-r--r--macos/Sources/Features/Terminal/Window Styles/HiddenTitlebarTerminalWindow.swift89
-rw-r--r--macos/Sources/Features/Terminal/Window Styles/Terminal.xib (renamed from macos/Sources/Features/Terminal/Terminal.xib)8
-rw-r--r--macos/Sources/Features/Terminal/Window Styles/TerminalHiddenTitlebar.xib31
-rw-r--r--macos/Sources/Features/Terminal/Window Styles/TerminalTabsTitlebarTahoe.xib31
-rw-r--r--macos/Sources/Features/Terminal/Window Styles/TerminalTabsTitlebarVentura.xib31
-rw-r--r--macos/Sources/Features/Terminal/Window Styles/TerminalTransparentTitlebar.xib31
-rw-r--r--macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift480
-rw-r--r--macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift262
-rw-r--r--macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift (renamed from macos/Sources/Features/Terminal/TerminalWindow.swift)441
-rw-r--r--macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift198
-rw-r--r--macos/Sources/Ghostty/AppError.swift3
-rw-r--r--macos/Sources/Ghostty/Ghostty.App.swift54
-rw-r--r--macos/Sources/Ghostty/Ghostty.Command.swift46
-rw-r--r--macos/Sources/Ghostty/Ghostty.Config.swift39
-rw-r--r--macos/Sources/Ghostty/Ghostty.Error.swift12
-rw-r--r--macos/Sources/Ghostty/Ghostty.Input.swift1270
-rw-r--r--macos/Sources/Ghostty/Ghostty.SplitNode.swift494
-rw-r--r--macos/Sources/Ghostty/Ghostty.Surface.swift149
-rw-r--r--macos/Sources/Ghostty/Ghostty.TerminalSplit.swift472
-rw-r--r--macos/Sources/Ghostty/InspectorView.swift4
-rw-r--r--macos/Sources/Ghostty/Package.swift15
-rw-r--r--macos/Sources/Ghostty/SurfaceView.swift164
-rw-r--r--macos/Sources/Ghostty/SurfaceView_AppKit.swift478
-rw-r--r--macos/Sources/Ghostty/SurfaceView_UIKit.swift6
-rw-r--r--macos/Sources/Helpers/AppInfo.swift (renamed from macos/Sources/Helpers/Xcode.swift)0
-rw-r--r--macos/Sources/Helpers/ExpiringUndoManager.swift148
-rw-r--r--macos/Sources/Helpers/Extensions/Array+Extension.swift48
-rw-r--r--macos/Sources/Helpers/Extensions/Double+Extension.swift5
-rw-r--r--macos/Sources/Helpers/Extensions/Duration+Extension.swift8
-rw-r--r--macos/Sources/Helpers/Extensions/EventModifiers+Extension.swift (renamed from macos/Sources/Helpers/EventModifiers+Extension.swift)0
-rw-r--r--macos/Sources/Helpers/Extensions/KeyboardShortcut+Extension.swift (renamed from macos/Sources/Helpers/KeyboardShortcut+Extension.swift)0
-rw-r--r--macos/Sources/Helpers/Extensions/NSAppearance+Extension.swift (renamed from macos/Sources/Helpers/NSAppearance+Extension.swift)0
-rw-r--r--macos/Sources/Helpers/Extensions/NSApplication+Extension.swift (renamed from macos/Sources/Helpers/NSApplication+Extension.swift)1
-rw-r--r--macos/Sources/Helpers/Extensions/NSImage+Extension.swift (renamed from macos/Sources/Helpers/NSImage+Extension.swift)0
-rw-r--r--macos/Sources/Helpers/Extensions/NSMenuItem+Extension.swift11
-rw-r--r--macos/Sources/Helpers/Extensions/NSPasteboard+Extension.swift (renamed from macos/Sources/Helpers/NSPasteboard+Extension.swift)0
-rw-r--r--macos/Sources/Helpers/Extensions/NSScreen+Extension.swift (renamed from macos/Sources/Helpers/NSScreen+Extension.swift)0
-rw-r--r--macos/Sources/Helpers/Extensions/NSView+Extension.swift221
-rw-r--r--macos/Sources/Helpers/Extensions/NSWindow+Extension.swift (renamed from macos/Sources/Helpers/NSWindow+Extension.swift)6
-rw-r--r--macos/Sources/Helpers/Extensions/OSColor+Extension.swift (renamed from macos/Sources/Helpers/OSColor+Extension.swift)0
-rw-r--r--macos/Sources/Helpers/Extensions/Optional+Extension.swift10
-rw-r--r--macos/Sources/Helpers/Extensions/String+Extension.swift (renamed from macos/Sources/Helpers/String+Extension.swift)0
-rw-r--r--macos/Sources/Helpers/Extensions/UndoManager+Extension.swift20
-rw-r--r--macos/Sources/Helpers/Extensions/View+Extension.swift (renamed from macos/Sources/Helpers/View+Extension.swift)0
-rw-r--r--macos/Sources/Helpers/Fullscreen.swift60
-rw-r--r--macos/Sources/Helpers/NSView+Extension.swift44
-rw-r--r--macos/Sources/Helpers/PermissionRequest.swift213
-rw-r--r--macos/Sources/Helpers/TabGroupCloseCoordinator.swift124
-rw-r--r--nix/devShell.nix4
-rw-r--r--pkg/README.md2
-rw-r--r--pkg/apple-sdk/build.zig91
-rw-r--r--pkg/breakpad/build.zig2
-rw-r--r--pkg/cimgui/build.zig3
-rw-r--r--pkg/fontconfig/build.zig18
-rw-r--r--pkg/fontconfig/pattern.zig4
-rw-r--r--pkg/freetype/build.zig2
-rw-r--r--pkg/glfw/LICENSE2
-rw-r--r--pkg/glfw/Monitor.zig2
-rw-r--r--pkg/glfw/build.zig5
-rw-r--r--pkg/glfw/opengl.zig2
-rw-r--r--pkg/glslang/build.zig6
-rw-r--r--pkg/gtk4-layer-shell/src/main.zig18
-rw-r--r--pkg/harfbuzz/build.zig3
-rw-r--r--pkg/highway/build.zig3
-rw-r--r--pkg/libintl/build.zig2
-rw-r--r--pkg/libpng/build.zig2
-rw-r--r--pkg/macos/animation.zig2
-rw-r--r--pkg/macos/build.zig7
-rw-r--r--pkg/macos/dispatch.zig10
-rw-r--r--pkg/macos/foundation.zig1
-rw-r--r--pkg/macos/foundation/type.zig1
-rw-r--r--pkg/macos/iosurface.zig8
-rw-r--r--pkg/macos/iosurface/c.zig1
-rw-r--r--pkg/macos/iosurface/iosurface.zig136
-rw-r--r--pkg/macos/main.zig3
-rw-r--r--pkg/macos/video.zig2
-rw-r--r--pkg/macos/video/pixel_format.zig171
-rw-r--r--pkg/oniguruma/build.zig2
-rw-r--r--pkg/opengl/Buffer.zig7
-rw-r--r--pkg/opengl/Framebuffer.zig24
-rw-r--r--pkg/opengl/Renderbuffer.zig56
-rw-r--r--pkg/opengl/Texture.zig46
-rw-r--r--pkg/opengl/VertexArray.zig84
-rw-r--r--pkg/opengl/draw.zig39
-rw-r--r--pkg/opengl/main.zig9
-rw-r--r--pkg/opengl/primitives.zig18
-rw-r--r--pkg/sentry/build.zig3
-rw-r--r--pkg/simdutf/build.zig2
-rw-r--r--pkg/spirv-cross/build.zig2
-rw-r--r--pkg/utfcpp/build.zig2
-rw-r--r--pkg/wuffs/build.zig5
-rw-r--r--pkg/wuffs/src/main.zig3
-rw-r--r--pkg/wuffs/src/swizzle.zig18
-rw-r--r--pkg/zlib/build.zig2
-rw-r--r--po/ca_ES.UTF-8.po2
-rw-r--r--po/com.mitchellh.ghostty.pot53
-rw-r--r--po/de_DE.UTF-8.po2
-rw-r--r--po/es_BO.UTF-8.po2
-rw-r--r--po/fr_FR.UTF-8.po2
-rw-r--r--po/id_ID.UTF-8.po2
-rw-r--r--po/ja_JP.UTF-8.po2
-rw-r--r--po/mk_MK.UTF-8.po2
-rw-r--r--po/nb_NO.UTF-8.po16
-rw-r--r--po/nl_NL.UTF-8.po2
-rw-r--r--po/pl_PL.UTF-8.po2
-rw-r--r--po/pt_BR.UTF-8.po8
-rw-r--r--po/ru_RU.UTF-8.po2
-rw-r--r--po/tr_TR.UTF-8.po2
-rw-r--r--po/uk_UA.UTF-8.po2
-rw-r--r--po/zh_CN.UTF-8.po53
-rw-r--r--snap/snapcraft.yaml7
-rw-r--r--src/App.zig34
-rw-r--r--src/Command.zig4
-rw-r--r--src/Surface.zig1088
-rw-r--r--src/apprt/action.zig13
-rw-r--r--src/apprt/browser.zig2
-rw-r--r--src/apprt/embedded.zig386
-rw-r--r--src/apprt/glfw.zig9
-rw-r--r--src/apprt/gtk.zig1
-rw-r--r--src/apprt/gtk/App.zig93
-rw-r--r--src/apprt/gtk/ClipboardConfirmationWindow.zig50
-rw-r--r--src/apprt/gtk/CommandPalette.zig16
-rw-r--r--src/apprt/gtk/ConfigErrorsDialog.zig6
-rw-r--r--src/apprt/gtk/GlobalShortcuts.zig4
-rw-r--r--src/apprt/gtk/ResizeOverlay.zig4
-rw-r--r--src/apprt/gtk/Split.zig2
-rw-r--r--src/apprt/gtk/Surface.zig56
-rw-r--r--src/apprt/gtk/TabView.zig15
-rw-r--r--src/apprt/gtk/Window.zig78
-rw-r--r--src/apprt/gtk/adw_version.zig4
-rw-r--r--src/apprt/gtk/flatpak.zig29
-rw-r--r--src/apprt/gtk/inspector.zig2
-rw-r--r--src/apprt/gtk/key.zig4
-rw-r--r--src/apprt/gtk/style.css6
-rw-r--r--src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp92
-rw-r--r--src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp90
-rw-r--r--src/apprt/gtk/winproto.zig6
-rw-r--r--src/apprt/gtk/winproto/noop.zig2
-rw-r--r--src/apprt/gtk/winproto/wayland.zig301
-rw-r--r--src/apprt/gtk/winproto/x11.zig38
-rw-r--r--src/apprt/none.zig2
-rw-r--r--src/apprt/surface.zig12
-rw-r--r--src/build/Config.zig12
-rw-r--r--src/build/GhosttyI18n.zig13
-rw-r--r--src/build/GhosttyResources.zig265
-rw-r--r--src/build/MetallibStep.zig34
-rw-r--r--src/build/SharedDeps.zig25
-rw-r--r--src/build/mdgen/ghostty_1_footer.md1
-rw-r--r--src/build/mdgen/ghostty_5_footer.md1
-rw-r--r--src/build/mdgen/mdgen.zig3
-rw-r--r--src/cli.zig2
-rw-r--r--src/cli/action.zig6
-rw-r--r--src/cli/args.zig211
-rw-r--r--src/cli/boo.zig2
-rw-r--r--src/cli/edit_config.zig159
-rw-r--r--src/cli/list_themes.zig5
-rw-r--r--src/config.zig4
-rw-r--r--src/config/Config.zig1019
-rw-r--r--src/config/edit.zig6
-rw-r--r--src/config/formatter.zig2
-rw-r--r--src/config/io.zig256
-rw-r--r--src/config/string.zig2
-rw-r--r--src/config/theme.zig2
-rw-r--r--src/crash/sentry.zig7
-rw-r--r--src/crash/sentry_envelope.zig2
-rw-r--r--src/datastruct/array_list_collection.zig44
-rw-r--r--src/datastruct/cache_table.zig2
-rw-r--r--src/datastruct/circ_buf.zig35
-rw-r--r--src/file_type.zig102
-rw-r--r--src/font/Atlas.zig44
-rw-r--r--src/font/CodepointResolver.zig16
-rw-r--r--src/font/Collection.zig18
-rw-r--r--src/font/DeferredFace.zig2
-rw-r--r--src/font/SharedGrid.zig39
-rw-r--r--src/font/SharedGridSet.zig18
-rw-r--r--src/font/discovery.zig420
-rw-r--r--src/font/face/coretext.zig2
-rw-r--r--src/font/face/freetype.zig8
-rw-r--r--src/font/face/freetype_convert.zig2
-rw-r--r--src/font/shaper/coretext.zig10
-rw-r--r--src/font/shaper/feature.zig290
-rw-r--r--src/font/shaper/harfbuzz.zig8
-rw-r--r--src/font/sprite/Box.zig523
-rw-r--r--src/font/sprite/Face.zig5
-rw-r--r--src/font/sprite/canvas.zig2
-rw-r--r--src/font/sprite/cursor.zig6
-rw-r--r--src/font/sprite/testdata/Box.ppmbin1048593 -> 1048593 bytes
-rw-r--r--src/global.zig15
-rw-r--r--src/input/Binding.zig498
-rw-r--r--src/input/KeyEncoder.zig2
-rw-r--r--src/input/command.zig81
-rw-r--r--src/input/key.zig8
-rw-r--r--src/input/keycodes.zig5
-rw-r--r--src/inspector/termio.zig2
-rw-r--r--src/main_ghostty.zig5
-rw-r--r--src/os/args.zig2
-rw-r--r--src/os/cf_release_thread.zig8
-rw-r--r--src/os/cgroup.zig19
-rw-r--r--src/os/dbus.zig21
-rw-r--r--src/os/desktop.zig18
-rw-r--r--src/os/flatpak.zig25
-rw-r--r--src/os/homedir.zig4
-rw-r--r--src/os/hostname.zig194
-rw-r--r--src/os/i18n.zig38
-rw-r--r--src/os/locale.zig7
-rw-r--r--src/os/macos.zig4
-rw-r--r--src/os/main.zig6
-rw-r--r--src/os/open.zig91
-rw-r--r--src/os/resourcesdir.zig53
-rw-r--r--src/os/systemd.zig65
-rw-r--r--src/pty.zig4
-rw-r--r--src/renderer.zig5
-rw-r--r--src/renderer/Metal.zig3481
-rw-r--r--src/renderer/OpenGL.zig2809
-rw-r--r--src/renderer/Options.zig3
-rw-r--r--src/renderer/Thread.zig42
-rw-r--r--src/renderer/cell.zig331
-rw-r--r--src/renderer/cursor.zig2
-rw-r--r--src/renderer/generic.zig3216
-rw-r--r--src/renderer/image.zig302
-rw-r--r--src/renderer/link.zig2
-rw-r--r--src/renderer/metal/Frame.zig137
-rw-r--r--src/renderer/metal/IOSurfaceLayer.zig190
-rw-r--r--src/renderer/metal/Pipeline.zig208
-rw-r--r--src/renderer/metal/RenderPass.zig220
-rw-r--r--src/renderer/metal/Target.zig110
-rw-r--r--src/renderer/metal/Texture.zig201
-rw-r--r--src/renderer/metal/api.zig241
-rw-r--r--src/renderer/metal/buffer.zig77
-rw-r--r--src/renderer/metal/cell.zig358
-rw-r--r--src/renderer/metal/image.zig466
-rw-r--r--src/renderer/metal/sampler.zig38
-rw-r--r--src/renderer/metal/shaders.zig741
-rw-r--r--src/renderer/opengl/CellProgram.zig196
-rw-r--r--src/renderer/opengl/Frame.zig75
-rw-r--r--src/renderer/opengl/ImageProgram.zig134
-rw-r--r--src/renderer/opengl/Pipeline.zig170
-rw-r--r--src/renderer/opengl/RenderPass.zig141
-rw-r--r--src/renderer/opengl/Target.zig62
-rw-r--r--src/renderer/opengl/Texture.zig102
-rw-r--r--src/renderer/opengl/buffer.zig127
-rw-r--r--src/renderer/opengl/custom.zig310
-rw-r--r--src/renderer/opengl/image.zig426
-rw-r--r--src/renderer/opengl/shaders.zig375
-rw-r--r--src/renderer/shaders/cell.f.glsl53
-rw-r--r--src/renderer/shaders/cell.v.glsl258
-rw-r--r--src/renderer/shaders/custom.v.glsl8
-rw-r--r--src/renderer/shaders/glsl/bg_color.f.glsl13
-rw-r--r--src/renderer/shaders/glsl/bg_image.f.glsl63
-rw-r--r--src/renderer/shaders/glsl/bg_image.v.glsl145
-rw-r--r--src/renderer/shaders/glsl/cell_bg.f.glsl61
-rw-r--r--src/renderer/shaders/glsl/cell_text.f.glsl109
-rw-r--r--src/renderer/shaders/glsl/cell_text.v.glsl168
-rw-r--r--src/renderer/shaders/glsl/common.glsl156
-rw-r--r--src/renderer/shaders/glsl/full_screen.v.glsl24
-rw-r--r--src/renderer/shaders/glsl/image.f.glsl21
-rw-r--r--src/renderer/shaders/glsl/image.v.glsl47
-rw-r--r--src/renderer/shaders/image.f.glsl29
-rw-r--r--src/renderer/shaders/image.v.glsl44
-rw-r--r--src/renderer/shaders/shaders.metal (renamed from src/renderer/shaders/cell.metal)319
-rw-r--r--src/renderer/shaders/shadertoy_prefix.glsl35
-rw-r--r--src/renderer/shadertoy.zig44
-rw-r--r--src/renderer/size.zig4
-rw-r--r--src/shell-integration/bash/ghostty.bash6
-rw-r--r--src/terminal/PageList.zig229
-rw-r--r--src/terminal/Parser.zig25
-rw-r--r--src/terminal/Screen.zig108
-rw-r--r--src/terminal/Selection.zig8
-rw-r--r--src/terminal/StringMap.zig2
-rw-r--r--src/terminal/Tabstops.zig2
-rw-r--r--src/terminal/Terminal.zig472
-rw-r--r--src/terminal/bitmap_allocator.zig8
-rw-r--r--src/terminal/hash_map.zig42
-rw-r--r--src/terminal/kitty/graphics_command.zig18
-rw-r--r--src/terminal/kitty/graphics_exec.zig2
-rw-r--r--src/terminal/kitty/key.zig4
-rw-r--r--src/terminal/main.zig1
-rw-r--r--src/terminal/modes.zig2
-rw-r--r--src/terminal/osc.zig1379
-rw-r--r--src/terminal/page.zig27
-rw-r--r--src/terminal/point.zig10
-rw-r--r--src/terminal/ref_counted_set.zig6
-rw-r--r--src/terminal/search.zig4
-rw-r--r--src/terminal/sgr.zig4
-rw-r--r--src/terminal/stream.zig20
-rw-r--r--src/terminal/style.zig23
-rw-r--r--src/terminal/x11_color.zig2
-rw-r--r--src/terminfo/Source.zig2
-rw-r--r--src/termio/Exec.zig168
-rw-r--r--src/termio/Termio.zig148
-rw-r--r--src/termio/Thread.zig29
-rw-r--r--src/termio/backend.zig8
-rw-r--r--src/termio/message.zig10
-rw-r--r--src/termio/stream_handler.zig378
-rw-r--r--src/unicode/props.zig2
-rw-r--r--typos.toml2
-rw-r--r--vendor/glad/include/glad/gl.h884
-rw-r--r--vendor/glad/include/glad/glad.h1
-rw-r--r--vendor/glad/src/gl.c304
380 files changed, 26748 insertions, 14342 deletions
diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml
index 11521c9c6..a905531c2 100644
--- a/.github/workflows/nix.yml
+++ b/.github/workflows/nix.yml
@@ -36,7 +36,7 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Cache
- uses: namespacelabs/nscloud-cache-action@v1.2.7
+ uses: namespacelabs/nscloud-cache-action@v1.2.8
with:
path: |
/nix
diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml
index 574b1ab73..a1cc2af19 100644
--- a/.github/workflows/release-pr.yml
+++ b/.github/workflows/release-pr.yml
@@ -47,7 +47,7 @@ jobs:
sentry-cli dif upload --project ghostty --wait dsym.zip
build-macos:
- runs-on: namespace-profile-ghostty-macos
+ runs-on: namespace-profile-ghostty-macos-sequoia
timeout-minutes: 90
steps:
- name: Checkout code
@@ -94,7 +94,7 @@ jobs:
- name: Build Ghostty.app
run: |
cd macos
- sudo xcode-select -s /Applications/Xcode_16.0.app
+ sudo xcode-select -s /Applications/Xcode_26.0.app
xcodebuild -target Ghostty -configuration Release
# We inject the "build number" as simply the number of commits since HEAD.
@@ -199,7 +199,7 @@ jobs:
destination-dir: ./
build-macos-debug:
- runs-on: namespace-profile-ghostty-macos
+ runs-on: namespace-profile-ghostty-macos-sequoia
timeout-minutes: 90
steps:
- name: Checkout code
@@ -246,7 +246,7 @@ jobs:
- name: Build Ghostty.app
run: |
cd macos
- sudo xcode-select -s /Applications/Xcode_16.0.app
+ sudo xcode-select -s /Applications/Xcode_26.0.app
xcodebuild -target Ghostty -configuration Release
# We inject the "build number" as simply the number of commits since HEAD.
diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml
index 6190bed16..3deafd066 100644
--- a/.github/workflows/release-tag.yml
+++ b/.github/workflows/release-tag.yml
@@ -83,7 +83,7 @@ jobs:
- uses: actions/checkout@v4
- name: Setup Cache
- uses: namespacelabs/nscloud-cache-action@v1.2.7
+ uses: namespacelabs/nscloud-cache-action@v1.2.8
with:
path: |
/nix
@@ -120,7 +120,7 @@ jobs:
build-macos:
needs: [setup]
- runs-on: namespace-profile-ghostty-macos
+ runs-on: namespace-profile-ghostty-macos-sequoia
timeout-minutes: 90
env:
GHOSTTY_VERSION: ${{ needs.setup.outputs.version }}
@@ -139,7 +139,7 @@ jobs:
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
- name: XCode Select
- run: sudo xcode-select -s /Applications/Xcode_16.0.app
+ run: sudo xcode-select -s /Applications/Xcode_16.4.app
- name: Setup Sparkle
env:
@@ -288,7 +288,7 @@ jobs:
appcast:
needs: [setup, build-macos]
- runs-on: namespace-profile-ghostty-macos
+ runs-on: namespace-profile-ghostty-macos-sequoia
env:
GHOSTTY_VERSION: ${{ needs.setup.outputs.version }}
GHOSTTY_BUILD: ${{ needs.setup.outputs.build }}
diff --git a/.github/workflows/release-tip.yml b/.github/workflows/release-tip.yml
index b4a341a5d..2a3277ea6 100644
--- a/.github/workflows/release-tip.yml
+++ b/.github/workflows/release-tip.yml
@@ -107,7 +107,7 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Setup Cache
- uses: namespacelabs/nscloud-cache-action@v1.2.7
+ uses: namespacelabs/nscloud-cache-action@v1.2.8
with:
path: |
/nix
@@ -132,7 +132,7 @@ jobs:
nix develop -c minisign -S -m ghostty-source.tar.gz -s minisign.key < minisign.password
- name: Update Release
- uses: softprops/action-gh-release@v2
+ uses: softprops/action-gh-release@v2.3.2
with:
name: 'Ghostty Tip ("Nightly")'
prerelease: true
@@ -154,7 +154,7 @@ jobs:
)
}}
- runs-on: namespace-profile-ghostty-macos
+ runs-on: namespace-profile-ghostty-macos-sequoia
timeout-minutes: 90
steps:
- name: Checkout code
@@ -173,7 +173,7 @@ jobs:
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
- name: XCode Select
- run: sudo xcode-select -s /Applications/Xcode_16.0.app
+ run: sudo xcode-select -s /Applications/Xcode_26.0.app
# Setup Sparkle
- name: Setup Sparkle
@@ -299,7 +299,7 @@ jobs:
# Update Release
- name: Release
- uses: softprops/action-gh-release@v2
+ uses: softprops/action-gh-release@v2.3.2
with:
name: 'Ghostty Tip ("Nightly")'
prerelease: true
@@ -369,7 +369,7 @@ jobs:
)
}}
- runs-on: namespace-profile-ghostty-macos
+ runs-on: namespace-profile-ghostty-macos-sequoia
timeout-minutes: 90
steps:
- name: Checkout code
@@ -388,7 +388,7 @@ jobs:
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
- name: XCode Select
- run: sudo xcode-select -s /Applications/Xcode_16.0.app
+ run: sudo xcode-select -s /Applications/Xcode_26.0.app
# Setup Sparkle
- name: Setup Sparkle
@@ -507,7 +507,7 @@ jobs:
# Update Release
- name: Release
- uses: softprops/action-gh-release@v2
+ uses: softprops/action-gh-release@v2.3.2
with:
name: 'Ghostty Tip ("Nightly")'
prerelease: true
@@ -544,7 +544,7 @@ jobs:
)
}}
- runs-on: namespace-profile-ghostty-macos
+ runs-on: namespace-profile-ghostty-macos-sequoia
timeout-minutes: 90
steps:
- name: Checkout code
@@ -563,7 +563,7 @@ jobs:
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
- name: XCode Select
- run: sudo xcode-select -s /Applications/Xcode_16.0.app
+ run: sudo xcode-select -s /Applications/Xcode_26.0.app
# Setup Sparkle
- name: Setup Sparkle
@@ -682,7 +682,7 @@ jobs:
# Update Release
- name: Release
- uses: softprops/action-gh-release@v2
+ uses: softprops/action-gh-release@v2.3.2
with:
name: 'Ghostty Tip ("Nightly")'
prerelease: true
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 1401f8325..4d09603f4 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -18,6 +18,7 @@ jobs:
- build-nix
- build-snap
- build-macos
+ - build-macos-tahoe
- build-macos-matrix
- build-windows
- build-windows-cross
@@ -67,7 +68,7 @@ jobs:
uses: actions/checkout@v4
- name: Setup Cache
- uses: namespacelabs/nscloud-cache-action@v1.2.7
+ uses: namespacelabs/nscloud-cache-action@v1.2.8
with:
path: |
/nix
@@ -98,7 +99,7 @@ jobs:
uses: actions/checkout@v4
- name: Setup Cache
- uses: namespacelabs/nscloud-cache-action@v1.2.7
+ uses: namespacelabs/nscloud-cache-action@v1.2.8
with:
path: |
/nix
@@ -134,7 +135,7 @@ jobs:
uses: actions/checkout@v4
- name: Setup Cache
- uses: namespacelabs/nscloud-cache-action@v1.2.7
+ uses: namespacelabs/nscloud-cache-action@v1.2.8
with:
path: |
/nix
@@ -163,7 +164,7 @@ jobs:
uses: actions/checkout@v4
- name: Setup Cache
- uses: namespacelabs/nscloud-cache-action@v1.2.7
+ uses: namespacelabs/nscloud-cache-action@v1.2.8
with:
path: |
/nix
@@ -196,7 +197,7 @@ jobs:
uses: actions/checkout@v4
- name: Setup Cache
- uses: namespacelabs/nscloud-cache-action@v1.2.7
+ uses: namespacelabs/nscloud-cache-action@v1.2.8
with:
path: |
/nix
@@ -240,7 +241,7 @@ jobs:
uses: actions/checkout@v4
- name: Setup Cache
- uses: namespacelabs/nscloud-cache-action@v1.2.7
+ uses: namespacelabs/nscloud-cache-action@v1.2.8
with:
path: |
/nix
@@ -269,7 +270,7 @@ jobs:
ghostty-source.tar.gz
build-macos:
- runs-on: namespace-profile-ghostty-macos
+ runs-on: namespace-profile-ghostty-macos-sequoia
needs: test
steps:
- name: Checkout code
@@ -284,8 +285,8 @@ jobs:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
- - name: XCode Select
- run: sudo xcode-select -s /Applications/Xcode_16.0.app
+ - name: Xcode Select
+ run: sudo xcode-select -s /Applications/Xcode_26.0.app
- name: get the Zig deps
id: deps
@@ -296,7 +297,47 @@ jobs:
- name: Build GhosttyKit
run: nix develop -c zig build --system ${{ steps.deps.outputs.deps }}
- # The native app is built with native XCode tooling. This also does
+ # The native app is built with native Xcode tooling. This also does
+ # codesigning. IMPORTANT: this must NOT run in a Nix environment.
+ # Nix breaks xcodebuild so this has to be run outside.
+ - name: Build Ghostty.app
+ run: cd macos && xcodebuild -target Ghostty
+
+ # Build the iOS target without code signing just to verify it works.
+ - name: Build Ghostty iOS
+ run: |
+ cd macos
+ xcodebuild -target Ghostty-iOS "CODE_SIGNING_ALLOWED=NO"
+
+ build-macos-tahoe:
+ runs-on: namespace-profile-ghostty-macos-tahoe
+ needs: test
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ # TODO(tahoe): https://github.com/NixOS/nix/issues/13342
+ - uses: DeterminateSystems/nix-installer-action@main
+ with:
+ determinate: true
+ - uses: cachix/cachix-action@v16
+ with:
+ name: ghostty
+ authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
+
+ - name: Xcode Select
+ run: sudo xcode-select -s /Applications/Xcode_26.0.app
+
+ - name: get the Zig deps
+ id: deps
+ run: nix build -L .#deps && echo "deps=$(readlink ./result)" >> $GITHUB_OUTPUT
+
+ # GhosttyKit is the framework that is built from Zig for our native
+ # Mac app to access.
+ - name: Build GhosttyKit
+ run: nix develop -c zig build --system ${{ steps.deps.outputs.deps }}
+
+ # The native app is built with native Xcode tooling. This also does
# codesigning. IMPORTANT: this must NOT run in a Nix environment.
# Nix breaks xcodebuild so this has to be run outside.
- name: Build Ghostty.app
@@ -309,7 +350,7 @@ jobs:
xcodebuild -target Ghostty-iOS "CODE_SIGNING_ALLOWED=NO"
build-macos-matrix:
- runs-on: namespace-profile-ghostty-macos
+ runs-on: namespace-profile-ghostty-macos-sequoia
needs: test
steps:
- name: Checkout code
@@ -324,8 +365,8 @@ jobs:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
- - name: XCode Select
- run: sudo xcode-select -s /Applications/Xcode_16.0.app
+ - name: Xcode Select
+ run: sudo xcode-select -s /Applications/Xcode_26.0.app
- name: get the Zig deps
id: deps
@@ -382,7 +423,7 @@ jobs:
mkdir dist
tar --verbose --extract --strip-components 1 --directory dist --file ghostty-source.tar.gz
- name: Setup Cache
- uses: namespacelabs/nscloud-cache-action@v1.2.7
+ uses: namespacelabs/nscloud-cache-action@v1.2.8
with:
path: |
/nix
@@ -492,7 +533,7 @@ jobs:
uses: actions/checkout@v4
- name: Setup Cache
- uses: namespacelabs/nscloud-cache-action@v1.2.7
+ uses: namespacelabs/nscloud-cache-action@v1.2.8
with:
path: |
/nix
@@ -523,7 +564,7 @@ jobs:
uses: actions/checkout@v4
- name: Setup Cache
- uses: namespacelabs/nscloud-cache-action@v1.2.7
+ uses: namespacelabs/nscloud-cache-action@v1.2.8
with:
path: |
/nix
@@ -568,7 +609,7 @@ jobs:
uses: actions/checkout@v4
- name: Setup Cache
- uses: namespacelabs/nscloud-cache-action@v1.2.7
+ uses: namespacelabs/nscloud-cache-action@v1.2.8
with:
path: |
/nix
@@ -607,7 +648,7 @@ jobs:
uses: actions/checkout@v4
- name: Setup Cache
- uses: namespacelabs/nscloud-cache-action@v1.2.7
+ uses: namespacelabs/nscloud-cache-action@v1.2.8
with:
path: |
/nix
@@ -627,7 +668,7 @@ jobs:
nix develop -c zig build -Dsentry=${{ matrix.sentry }}
test-macos:
- runs-on: namespace-profile-ghostty-macos
+ runs-on: namespace-profile-ghostty-macos-sequoia
needs: test
steps:
- name: Checkout code
@@ -642,8 +683,8 @@ jobs:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
- - name: XCode Select
- run: sudo xcode-select -s /Applications/Xcode_16.0.app
+ - name: Xcode Select
+ run: sudo xcode-select -s /Applications/Xcode_26.0.app
- name: get the Zig deps
id: deps
@@ -662,7 +703,7 @@ jobs:
steps:
- uses: actions/checkout@v4 # Check out repo so we can lint it
- name: Setup Cache
- uses: namespacelabs/nscloud-cache-action@v1.2.7
+ uses: namespacelabs/nscloud-cache-action@v1.2.8
with:
path: |
/nix
@@ -689,7 +730,7 @@ jobs:
steps:
- uses: actions/checkout@v4 # Check out repo so we can lint it
- name: Setup Cache
- uses: namespacelabs/nscloud-cache-action@v1.2.7
+ uses: namespacelabs/nscloud-cache-action@v1.2.8
with:
path: |
/nix
@@ -716,7 +757,7 @@ jobs:
steps:
- uses: actions/checkout@v4 # Check out repo so we can lint it
- name: Setup Cache
- uses: namespacelabs/nscloud-cache-action@v1.2.7
+ uses: namespacelabs/nscloud-cache-action@v1.2.8
with:
path: |
/nix
@@ -743,7 +784,7 @@ jobs:
steps:
- uses: actions/checkout@v4 # Check out repo so we can lint it
- name: Setup Cache
- uses: namespacelabs/nscloud-cache-action@v1.2.7
+ uses: namespacelabs/nscloud-cache-action@v1.2.8
with:
path: |
/nix
@@ -770,7 +811,7 @@ jobs:
steps:
- uses: actions/checkout@v4 # Check out repo so we can lint it
- name: Setup Cache
- uses: namespacelabs/nscloud-cache-action@v1.2.7
+ uses: namespacelabs/nscloud-cache-action@v1.2.8
with:
path: |
/nix
@@ -797,7 +838,7 @@ jobs:
steps:
- uses: actions/checkout@v4 # Check out repo so we can lint it
- name: Setup Cache
- uses: namespacelabs/nscloud-cache-action@v1.2.7
+ uses: namespacelabs/nscloud-cache-action@v1.2.8
with:
path: |
/nix
@@ -832,7 +873,7 @@ jobs:
uses: actions/checkout@v4
- name: Setup Cache
- uses: namespacelabs/nscloud-cache-action@v1.2.7
+ uses: namespacelabs/nscloud-cache-action@v1.2.8
with:
path: |
/nix
@@ -890,7 +931,7 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Cache
- uses: namespacelabs/nscloud-cache-action@v1.2.7
+ uses: namespacelabs/nscloud-cache-action@v1.2.8
with:
path: |
/nix
diff --git a/.github/workflows/update-colorschemes.yml b/.github/workflows/update-colorschemes.yml
index 27b35b441..2533285e6 100644
--- a/.github/workflows/update-colorschemes.yml
+++ b/.github/workflows/update-colorschemes.yml
@@ -22,7 +22,7 @@ jobs:
fetch-depth: 0
- name: Setup Cache
- uses: namespacelabs/nscloud-cache-action@v1.2.7
+ uses: namespacelabs/nscloud-cache-action@v1.2.8
with:
path: |
/nix
diff --git a/.prettierignore b/.prettierignore
index 490538680..f131a5edc 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -11,6 +11,9 @@ zig-out/
# macos is managed by XCode GUI
macos/
+# produced by Icon Composer on macOS
+images/Ghostty.icon/icon.json
+
# website dev run
website/.next
diff --git a/CODEOWNERS b/CODEOWNERS
index a53fb6da2..829a31e51 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.
#
diff --git a/LICENSE b/LICENSE
index 14e132f55..0a07a66cd 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,6 @@
MIT License
-Copyright (c) 2024 Mitchell Hashimoto
+Copyright (c) 2024 Mitchell Hashimoto, Ghostty contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/README.md b/README.md
index d5c9dba02..b59964e61 100644
--- a/README.md
+++ b/README.md
@@ -224,6 +224,28 @@ macOS users don't require any additional dependencies.
> source tarballs, see the
> [website](http://ghostty.org/docs/install/build).
+### Xcode Version and SDKs
+
+Building the Ghostty macOS app requires that Xcode, the macOS SDK,
+and the iOS SDK are all installed.
+
+A common issue is that the incorrect version of Xcode is either
+installed or selected. Use the `xcode-select` command to
+ensure that the correct version of Xcode is selected:
+
+```shell-session
+sudo xcode-select --switch /Applications/Xcode-beta.app
+```
+
+> [!IMPORTANT]
+>
+> Main branch development of Ghostty is preparing for the next major
+> macOS release, Tahoe (macOS 26). Therefore, the main branch requires
+> **Xcode 26 and the macOS 26 SDK**.
+>
+> You do not need to be running on macOS 26 to build Ghostty, you can
+> still use Xcode 26 beta on macOS 15 stable.
+
### Linting
#### Prettier
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 796ce1475..43986637f 100644
--- a/build.zig.zon
+++ b/build.zig.zon
@@ -8,8 +8,8 @@
.libxev = .{
// mitchellh/libxev
- .url = "https://github.com/mitchellh/libxev/archive/3df9337a9e84450a58a2c4af434ec1a036f7b494.tar.gz",
- .hash = "libxev-0.0.0-86vtc-ziEgDbLP0vihUn1MhsxNKY4GJEga6BEr7oyHpz",
+ .url = "https://github.com/mitchellh/libxev/archive/9bc52324d4f0c036a3b244e992680a9fb217bbd3.tar.gz",
+ .hash = "libxev-0.0.0-86vtc5b1EgB7vFmt9Tk7ySteR5AeEHW7xcR6gK9dMUD3",
.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/f979d8b1959d004390acede9f298be389cb9a1e0.tar.gz",
- .hash = "N-V-__8AANf-XQSCQIcmjPV_GQZLPBxaAgzzw_3UWOmkDUXn",
+ .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 68ec4522a..d9f43a766 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-__8AANf-XQSCQIcmjPV_GQZLPBxaAgzzw_3UWOmkDUXn": {
+ "N-V-__8AAGHcWgTaKLjwmFkxToNT4jgz5VXUHR7hz8TQ2_AS": {
"name": "iterm2_themes",
- "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/f979d8b1959d004390acede9f298be389cb9a1e0.tar.gz",
- "hash": "sha256-DKWVUxZEZA8x+3njPaTucr/u/Mmhef0YwhwOnOWn/N4="
+ "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-86vtc-ziEgDbLP0vihUn1MhsxNKY4GJEga6BEr7oyHpz": {
+ "libxev-0.0.0-86vtc5b1EgB7vFmt9Tk7ySteR5AeEHW7xcR6gK9dMUD3": {
"name": "libxev",
- "url": "https://github.com/mitchellh/libxev/archive/3df9337a9e84450a58a2c4af434ec1a036f7b494.tar.gz",
- "hash": "sha256-oKZqA9d79jHnp/HsqJWQE33Ffn5Ee5G4VnlQepQuY4o="
+ "url": "https://github.com/mitchellh/libxev/archive/9bc52324d4f0c036a3b244e992680a9fb217bbd3.tar.gz",
+ "hash": "sha256-VwFByDoptqiN5UkolFQ7TbRhwMERReD9Er2pjxTCYIU="
},
"N-V-__8AAG3RoQEyRC2Vw7Qoro5SYBf62IHn3HjqtNVY6aWK": {
"name": "libxml2",
diff --git a/build.zig.zon.nix b/build.zig.zon.nix
index 7c3e08d2d..26209e778 100644
--- a/build.zig.zon.nix
+++ b/build.zig.zon.nix
@@ -170,11 +170,11 @@ in
};
}
{
- name = "N-V-__8AANf-XQSCQIcmjPV_GQZLPBxaAgzzw_3UWOmkDUXn";
+ name = "N-V-__8AAGHcWgTaKLjwmFkxToNT4jgz5VXUHR7hz8TQ2_AS";
path = fetchZigArtifact {
name = "iterm2_themes";
- url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/f979d8b1959d004390acede9f298be389cb9a1e0.tar.gz";
- hash = "sha256-DKWVUxZEZA8x+3njPaTucr/u/Mmhef0YwhwOnOWn/N4=";
+ 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-86vtc-ziEgDbLP0vihUn1MhsxNKY4GJEga6BEr7oyHpz";
+ name = "libxev-0.0.0-86vtc5b1EgB7vFmt9Tk7ySteR5AeEHW7xcR6gK9dMUD3";
path = fetchZigArtifact {
name = "libxev";
- url = "https://github.com/mitchellh/libxev/archive/3df9337a9e84450a58a2c4af434ec1a036f7b494.tar.gz";
- hash = "sha256-oKZqA9d79jHnp/HsqJWQE33Ffn5Ee5G4VnlQepQuY4o=";
+ url = "https://github.com/mitchellh/libxev/archive/9bc52324d4f0c036a3b244e992680a9fb217bbd3.tar.gz";
+ hash = "sha256-VwFByDoptqiN5UkolFQ7TbRhwMERReD9Er2pjxTCYIU=";
};
}
{
diff --git a/build.zig.zon.txt b/build.zig.zon.txt
index 0c71c80e4..553b0fb06 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/f979d8b1959d004390acede9f298be389cb9a1e0.tar.gz
-https://github.com/mitchellh/libxev/archive/3df9337a9e84450a58a2c4af434ec1a036f7b494.tar.gz
+https://github.com/mbadolato/iTerm2-Color-Schemes/archive/6fa671fdc1daf1fcfa025cb960ffa3e7373a2ed8.tar.gz
+https://github.com/mitchellh/libxev/archive/9bc52324d4f0c036a3b244e992680a9fb217bbd3.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
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
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 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
- <id>com.mitchellh.ghostty</id>
- <launchable type="desktop-id">com.mitchellh.ghostty.desktop</launchable>
- <name>Ghostty</name>
+ <id>@APPID@</id>
+ <launchable type="desktop-id">@APPID@.desktop</launchable>
+ <name>@NAME@</name>
<url type="homepage">https://ghostty.org</url>
<url type="help">https://ghostty.org/docs</url>
<url type="bugtracker">https://github.com/ghostty-org/ghostty/discussions</url>
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/dist/macos/Ghostty.icns b/dist/macos/Ghostty.icns
deleted file mode 100644
index 44a44711a..000000000
--- a/dist/macos/Ghostty.icns
+++ /dev/null
Binary files differ
diff --git a/dist/macos/Info.plist b/dist/macos/Info.plist
deleted file mode 100644
index 8283cc529..000000000
--- a/dist/macos/Info.plist
+++ /dev/null
@@ -1,17 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
-<plist version="1.0">
- <dict>
- <key>CFBundleExecutable</key>
- <string>ghostty</string>
- <key>CFBundleIdentifier</key>
- <string>com.mitchellh.ghostty</string>
- <key>CFBundleName</key>
- <string>Ghostty</string>
- <key>CFBundleDisplayName</key>
- <string>Ghostty</string>
- <key>CFBundleIconFile</key>
- <string>Ghostty.icns</string>
- </dict>
-</plist>
-
diff --git a/flake.lock b/flake.lock
index df09a9666..4b8ce405c 100644
--- a/flake.lock
+++ b/flake.lock
@@ -3,11 +3,11 @@
"flake-compat": {
"flake": false,
"locked": {
- "lastModified": 1733328505,
- "narHash": "sha256-NeCCThCEP3eCl2l/+27kNNK7QrwZB1IJCrXfrbv5oqU=",
+ "lastModified": 1747046372,
+ "narHash": "sha256-CIVLLkVgvHYbgI2UpXvIIBJ12HWgX+fjA8Xf8PUmqCY=",
"owner": "edolstra",
"repo": "flake-compat",
- "rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec",
+ "rev": "9100a0f413b0c601e0533d1d94ffd501ce2e7885",
"type": "github"
},
"original": {
@@ -34,44 +34,24 @@
"type": "github"
}
},
- "nixpkgs-stable": {
+ "nixpkgs": {
"locked": {
- "lastModified": 1741992157,
- "narHash": "sha256-nlIfTsTrMSksEJc1f7YexXiPVuzD1gOfeN1ggwZyUoc=",
- "owner": "nixos",
- "repo": "nixpkgs",
- "rev": "da4b122f63095ca1199bd4d526f9e26426697689",
- "type": "github"
+ "lastModified": 1748189127,
+ "narHash": "sha256-zRDR+EbbeObu4V2X5QCd2Bk5eltfDlCr5yvhBwUT6pY=",
+ "rev": "7c43f080a7f28b2774f3b3f43234ca11661bf334",
+ "type": "tarball",
+ "url": "https://releases.nixos.org/nixos/25.05/nixos-25.05.802491.7c43f080a7f2/nixexprs.tar.xz"
},
"original": {
- "owner": "nixos",
- "ref": "release-24.11",
- "repo": "nixpkgs",
- "type": "github"
- }
- },
- "nixpkgs-unstable": {
- "locked": {
- "lastModified": 1741865919,
- "narHash": "sha256-4thdbnP6dlbdq+qZWTsm4ffAwoS8Tiq1YResB+RP6WE=",
- "owner": "nixos",
- "repo": "nixpkgs",
- "rev": "573c650e8a14b2faa0041645ab18aed7e60f0c9a",
- "type": "github"
- },
- "original": {
- "owner": "nixos",
- "ref": "nixpkgs-unstable",
- "repo": "nixpkgs",
- "type": "github"
+ "type": "tarball",
+ "url": "https://channels.nixos.org/nixos-25.05/nixexprs.tar.xz"
}
},
"root": {
"inputs": {
"flake-compat": "flake-compat",
"flake-utils": "flake-utils",
- "nixpkgs-stable": "nixpkgs-stable",
- "nixpkgs-unstable": "nixpkgs-unstable",
+ "nixpkgs": "nixpkgs",
"zig": "zig",
"zon2nix": "zon2nix"
}
@@ -98,15 +78,15 @@
"flake-utils"
],
"nixpkgs": [
- "nixpkgs-stable"
+ "nixpkgs"
]
},
"locked": {
- "lastModified": 1741825901,
- "narHash": "sha256-aeopo+aXg5I2IksOPFN79usw7AeimH1+tjfuMzJHFdk=",
+ "lastModified": 1748261582,
+ "narHash": "sha256-3i0IL3s18hdDlbsf0/E+5kyPRkZwGPbSFngq5eToiAA=",
"owner": "mitchellh",
"repo": "zig-overlay",
- "rev": "0b14285e283f5a747f372fb2931835dd937c4383",
+ "rev": "aafb1b093fb838f7a02613b719e85ec912914221",
"type": "github"
},
"original": {
@@ -121,7 +101,7 @@
"flake-utils"
],
"nixpkgs": [
- "nixpkgs-unstable"
+ "nixpkgs"
]
},
"locked": {
diff --git a/flake.nix b/flake.nix
index d4c6aa6ca..6794afb11 100644
--- a/flake.nix
+++ b/flake.nix
@@ -2,12 +2,10 @@
description = "👻";
inputs = {
- nixpkgs-unstable.url = "github:nixos/nixpkgs/nixpkgs-unstable";
-
# We want to stay as up to date as possible but need to be careful that the
# glibc versions used by our dependencies from Nix are compatible with the
# system glibc that the user is building for.
- nixpkgs-stable.url = "github:nixos/nixpkgs/release-24.11";
+ nixpkgs.url = "https://channels.nixos.org/nixos-25.05/nixexprs.tar.xz";
flake-utils.url = "github:numtide/flake-utils";
# Used for shell.nix
@@ -19,7 +17,7 @@
zig = {
url = "github:mitchellh/zig-overlay";
inputs = {
- nixpkgs.follows = "nixpkgs-stable";
+ nixpkgs.follows = "nixpkgs";
flake-utils.follows = "flake-utils";
flake-compat.follows = "";
};
@@ -28,7 +26,7 @@
zon2nix = {
url = "github:jcollie/zon2nix?ref=56c159be489cc6c0e73c3930bd908ddc6fe89613";
inputs = {
- nixpkgs.follows = "nixpkgs-unstable";
+ nixpkgs.follows = "nixpkgs";
flake-utils.follows = "flake-utils";
};
};
@@ -36,24 +34,19 @@
outputs = {
self,
- nixpkgs-unstable,
- nixpkgs-stable,
+ nixpkgs,
zig,
zon2nix,
...
}:
- builtins.foldl' nixpkgs-stable.lib.recursiveUpdate {} (
+ builtins.foldl' nixpkgs.lib.recursiveUpdate {} (
builtins.map (
system: let
- pkgs-stable = nixpkgs-stable.legacyPackages.${system};
- pkgs-unstable = nixpkgs-unstable.legacyPackages.${system};
+ pkgs = nixpkgs.legacyPackages.${system};
in {
- devShell.${system} = pkgs-stable.callPackage ./nix/devShell.nix {
- zig = zig.packages.${system}."0.14.0";
- wraptest = pkgs-stable.callPackage ./nix/wraptest.nix {};
- uv = pkgs-unstable.uv;
- # remove once blueprint-compiler 0.16.0 is in the stable nixpkgs
- blueprint-compiler = pkgs-unstable.blueprint-compiler;
+ devShell.${system} = pkgs.callPackage ./nix/devShell.nix {
+ zig = zig.packages.${system}."0.14.1";
+ wraptest = pkgs.callPackage ./nix/wraptest.nix {};
zon2nix = zon2nix;
};
@@ -64,30 +57,29 @@
revision = self.shortRev or self.dirtyShortRev or "dirty";
};
in rec {
- deps = pkgs-unstable.callPackage ./build.zig.zon.nix {};
- ghostty-debug = pkgs-unstable.callPackage ./nix/package.nix (mkArgs "Debug");
- ghostty-releasesafe = pkgs-unstable.callPackage ./nix/package.nix (mkArgs "ReleaseSafe");
- ghostty-releasefast = pkgs-unstable.callPackage ./nix/package.nix (mkArgs "ReleaseFast");
+ deps = pkgs.callPackage ./build.zig.zon.nix {};
+ ghostty-debug = pkgs.callPackage ./nix/package.nix (mkArgs "Debug");
+ ghostty-releasesafe = pkgs.callPackage ./nix/package.nix (mkArgs "ReleaseSafe");
+ ghostty-releasefast = pkgs.callPackage ./nix/package.nix (mkArgs "ReleaseFast");
ghostty = ghostty-releasefast;
default = ghostty;
};
- formatter.${system} = pkgs-stable.alejandra;
+ formatter.${system} = pkgs.alejandra;
apps.${system} = let
runVM = (
module: let
vm = import ./nix/vm/create.nix {
- inherit system module;
- nixpkgs = nixpkgs-unstable;
+ inherit system module nixpkgs;
overlay = self.overlays.debug;
};
- program = pkgs-unstable.writeShellScript "run-ghostty-vm" ''
+ program = pkgs.writeShellScript "run-ghostty-vm" ''
SHARED_DIR=$(pwd)
export SHARED_DIR
- ${pkgs-unstable.lib.getExe vm.config.system.build.vm} "$@"
+ ${pkgs.lib.getExe vm.config.system.build.vm} "$@"
'';
in {
type = "app";
diff --git a/flatpak/com.mitchellh.ghostty.Devel.yml b/flatpak/com.mitchellh.ghostty-debug.yml
index fe24a7c56..fe4722ef5 100644
--- a/flatpak/com.mitchellh.ghostty.Devel.yml
+++ b/flatpak/com.mitchellh.ghostty-debug.yml
@@ -1,4 +1,4 @@
-app-id: com.mitchellh.ghostty.Devel
+app-id: com.mitchellh.ghostty-debug
runtime: org.gnome.Platform
runtime-version: "48"
sdk: org.gnome.Sdk
@@ -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: " (Devel)"
finish-args:
# 3D rendering
- --device=dri
diff --git a/flatpak/zig-packages.json b/flatpak/zig-packages.json
index 2ee48f269..4990f794a 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/f979d8b1959d004390acede9f298be389cb9a1e0.tar.gz",
- "dest": "vendor/p/N-V-__8AANf-XQSCQIcmjPV_GQZLPBxaAgzzw_3UWOmkDUXn",
- "sha256": "0ca595531644640f31fb79e33da4ee72bfeefcc9a179fd18c21c0e9ce5a7fcde"
+ "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/3df9337a9e84450a58a2c4af434ec1a036f7b494.tar.gz",
- "dest": "vendor/p/libxev-0.0.0-86vtc-ziEgDbLP0vihUn1MhsxNKY4GJEga6BEr7oyHpz",
- "sha256": "a0a66a03d77bf631e7a7f1eca89590137dc57e7e447b91b85679507a942e638a"
+ "url": "https://github.com/mitchellh/libxev/archive/9bc52324d4f0c036a3b244e992680a9fb217bbd3.tar.gz",
+ "dest": "vendor/p/libxev-0.0.0-86vtc5b1EgB7vFmt9Tk7ySteR5AeEHW7xcR6gK9dMUD3",
+ "sha256": "570141c83a29b6a88de5492894543b4db461c0c11145e0fd12bda98f14c26085"
},
{
"type": "archive",
diff --git a/images/Ghostty.icon/Assets/Ghostty.png b/images/Ghostty.icon/Assets/Ghostty.png
new file mode 100644
index 000000000..49795c006
--- /dev/null
+++ b/images/Ghostty.icon/Assets/Ghostty.png
Binary files differ
diff --git a/images/Ghostty.icon/Assets/Inner Bevel 6px.png b/images/Ghostty.icon/Assets/Inner Bevel 6px.png
new file mode 100644
index 000000000..678193779
--- /dev/null
+++ b/images/Ghostty.icon/Assets/Inner Bevel 6px.png
Binary files differ
diff --git a/images/Ghostty.icon/Assets/Screen Effects.png b/images/Ghostty.icon/Assets/Screen Effects.png
new file mode 100644
index 000000000..0af7d3338
--- /dev/null
+++ b/images/Ghostty.icon/Assets/Screen Effects.png
Binary files differ
diff --git a/images/Ghostty.icon/Assets/Screen.png b/images/Ghostty.icon/Assets/Screen.png
new file mode 100644
index 000000000..2023b6ffa
--- /dev/null
+++ b/images/Ghostty.icon/Assets/Screen.png
Binary files differ
diff --git a/images/Ghostty.icon/Assets/gloss.png b/images/Ghostty.icon/Assets/gloss.png
new file mode 100644
index 000000000..f11196010
--- /dev/null
+++ b/images/Ghostty.icon/Assets/gloss.png
Binary files differ
diff --git a/images/Ghostty.icon/icon.json b/images/Ghostty.icon/icon.json
new file mode 100644
index 000000000..b29c9d81f
--- /dev/null
+++ b/images/Ghostty.icon/icon.json
@@ -0,0 +1,170 @@
+{
+ "color-space-for-untagged-svg-colors" : "display-p3",
+ "fill" : {
+ "linear-gradient" : [
+ "display-p3:0.87945,0.87945,0.87945,1.00000",
+ "display-p3:0.40000,0.40000,0.40392,1.00000"
+ ]
+ },
+ "groups" : [
+ {
+ "blend-mode" : "normal",
+ "layers" : [
+ {
+ "blend-mode" : "overlay",
+ "fill" : {
+ "linear-gradient" : [
+ "srgb:1.00000,1.00000,1.00000,1.00000",
+ "srgb:0.00000,0.00000,0.00000,1.00000"
+ ]
+ },
+ "hidden" : false,
+ "image-name" : "gloss.png",
+ "name" : "GlossTop",
+ "opacity" : 0.25,
+ "position" : {
+ "scale" : 0.98,
+ "translation-in-points" : [
+ 0.90625,
+ -236.4609375
+ ]
+ }
+ },
+ {
+ "blend-mode" : "normal",
+ "fill" : "automatic",
+ "hidden" : false,
+ "image-name" : "gloss.png",
+ "name" : "gloss",
+ "position" : {
+ "scale" : 0.98,
+ "translation-in-points" : [
+ 0.90625,
+ -236.4609375
+ ]
+ }
+ }
+ ],
+ "lighting" : "individual",
+ "name" : "Group 4",
+ "shadow" : {
+ "kind" : "neutral",
+ "opacity" : 0.5
+ },
+ "translucency" : {
+ "enabled" : true,
+ "value" : 0.5
+ }
+ },
+ {
+ "blend-mode" : "overlay",
+ "layers" : [
+ {
+ "blend-mode" : "overlay",
+ "fill" : "automatic",
+ "glass" : false,
+ "hidden" : false,
+ "image-name" : "Screen Effects.png",
+ "name" : "Screen Effects"
+ },
+ {
+ "blend-mode" : "overlay",
+ "fill" : "automatic",
+ "glass" : true,
+ "hidden" : false,
+ "image-name" : "Screen Effects.png",
+ "name" : "Screen Effects"
+ }
+ ],
+ "lighting" : "individual",
+ "name" : "Group 3",
+ "shadow" : {
+ "kind" : "neutral",
+ "opacity" : 0.5
+ },
+ "translucency" : {
+ "enabled" : false,
+ "value" : 0.5
+ }
+ },
+ {
+ "blur-material" : null,
+ "layers" : [
+ {
+ "blend-mode" : "normal",
+ "fill" : "automatic",
+ "hidden" : false,
+ "image-name" : "Ghostty.png",
+ "name" : "Ghostty",
+ "position" : {
+ "scale" : 1,
+ "translation-in-points" : [
+ -185.015625,
+ -143.8359375
+ ]
+ }
+ },
+ {
+ "blend-mode" : "normal",
+ "fill" : {
+ "solid" : "extended-srgb:0.00000,0.47843,1.00000,1.00000"
+ },
+ "glass" : true,
+ "hidden" : false,
+ "image-name" : "Ghostty.png",
+ "name" : "GhosttyBlur",
+ "position" : {
+ "scale" : 1,
+ "translation-in-points" : [
+ -186.59375,
+ -143.8359375
+ ]
+ }
+ },
+ {
+ "hidden" : false,
+ "image-name" : "Screen.png",
+ "name" : "Screen"
+ }
+ ],
+ "lighting" : "individual",
+ "name" : "Group 2",
+ "shadow" : {
+ "kind" : "neutral",
+ "opacity" : 0.5
+ },
+ "translucency" : {
+ "enabled" : false,
+ "value" : 0.5
+ }
+ },
+ {
+ "blend-mode" : "normal",
+ "blur-material" : null,
+ "hidden" : false,
+ "layers" : [
+ {
+ "image-name" : "Inner Bevel 6px.png",
+ "name" : "Inner Bevel 6px"
+ }
+ ],
+ "lighting" : "individual",
+ "name" : "Group 1",
+ "shadow" : {
+ "kind" : "layer-color",
+ "opacity" : 0.2
+ },
+ "specular" : false,
+ "translucency" : {
+ "enabled" : false,
+ "value" : 0.5
+ }
+ }
+ ],
+ "supported-platforms" : {
+ "circles" : [
+ "watchOS"
+ ],
+ "squares" : "shared"
+ }
+} \ No newline at end of file
diff --git a/images/icons/icon_1024.png b/images/icons/icon_1024.png
index a0b716c87..22361edcb 100644
--- a/images/icons/icon_1024.png
+++ b/images/icons/icon_1024.png
Binary files differ
diff --git a/images/icons/icon_1024@2x.png b/images/icons/icon_1024@2x.png
new file mode 100644
index 000000000..22361edcb
--- /dev/null
+++ b/images/icons/icon_1024@2x.png
Binary files differ
diff --git a/images/icons/icon_128.png b/images/icons/icon_128.png
index bad0eb891..317ad9f0f 100644
--- a/images/icons/icon_128.png
+++ b/images/icons/icon_128.png
Binary files differ
diff --git a/images/icons/icon_256.png b/images/icons/icon_256.png
index 803224416..9988ac11e 100644
--- a/images/icons/icon_256.png
+++ b/images/icons/icon_256.png
Binary files differ
diff --git a/images/icons/icon_256@2x.png b/images/icons/icon_256@2x.png
index b51b8d7dc..9988ac11e 100644
--- a/images/icons/icon_256@2x.png
+++ b/images/icons/icon_256@2x.png
Binary files differ
diff --git a/images/icons/icon_512.png b/images/icons/icon_512.png
index b51b8d7dc..759511f68 100644
--- a/images/icons/icon_512.png
+++ b/images/icons/icon_512.png
Binary files differ
diff --git a/images/icons/icon_512@2x.png b/images/icons/icon_512@2x.png
new file mode 100644
index 000000000..759511f68
--- /dev/null
+++ b/images/icons/icon_512@2x.png
Binary files differ
diff --git a/include/ghostty.h b/include/ghostty.h
index 941223943..181f7b7f8 100644
--- a/include/ghostty.h
+++ b/include/ghostty.h
@@ -292,6 +292,11 @@ typedef enum {
GHOSTTY_KEY_AUDIO_VOLUME_MUTE,
GHOSTTY_KEY_AUDIO_VOLUME_UP,
GHOSTTY_KEY_WAKE_UP,
+
+ // "Legacy, Non-standard, and Special Keys" § 3.7
+ GHOSTTY_KEY_COPY,
+ GHOSTTY_KEY_CUT,
+ GHOSTTY_KEY_PASTE,
} ghostty_input_key_e;
typedef struct {
@@ -350,9 +355,42 @@ typedef struct {
double tl_px_y;
uint32_t offset_start;
uint32_t offset_len;
+ const char* text;
+ uintptr_t text_len;
+} ghostty_text_s;
+
+typedef enum {
+ GHOSTTY_POINT_ACTIVE,
+ GHOSTTY_POINT_VIEWPORT,
+ GHOSTTY_POINT_SCREEN,
+ GHOSTTY_POINT_SURFACE,
+} ghostty_point_tag_e;
+
+typedef enum {
+ GHOSTTY_POINT_COORD_EXACT,
+ GHOSTTY_POINT_COORD_TOP_LEFT,
+ GHOSTTY_POINT_COORD_BOTTOM_RIGHT,
+} ghostty_point_coord_e;
+
+typedef struct {
+ ghostty_point_tag_e tag;
+ ghostty_point_coord_e coord;
+ uint32_t x;
+ uint32_t y;
+} ghostty_point_s;
+
+typedef struct {
+ ghostty_point_s top_left;
+ ghostty_point_s bottom_right;
+ bool rectangle;
} ghostty_selection_s;
typedef struct {
+ const char* key;
+ const char* value;
+} ghostty_env_var_s;
+
+typedef struct {
void* nsview;
} ghostty_platform_macos_s;
@@ -373,6 +411,9 @@ typedef struct {
float font_size;
const char* working_directory;
const char* command;
+ ghostty_env_var_s* env_vars;
+ size_t env_var_count;
+ const char* initial_input;
} ghostty_surface_config_s;
typedef struct {
@@ -648,6 +689,7 @@ typedef enum {
GHOSTTY_ACTION_INITIAL_SIZE,
GHOSTTY_ACTION_CELL_SIZE,
GHOSTTY_ACTION_INSPECTOR,
+ GHOSTTY_ACTION_SHOW_GTK_INSPECTOR,
GHOSTTY_ACTION_RENDER_INSPECTOR,
GHOSTTY_ACTION_DESKTOP_NOTIFICATION,
GHOSTTY_ACTION_SET_TITLE,
@@ -667,6 +709,8 @@ typedef enum {
GHOSTTY_ACTION_CONFIG_CHANGE,
GHOSTTY_ACTION_CLOSE_WINDOW,
GHOSTTY_ACTION_RING_BELL,
+ GHOSTTY_ACTION_UNDO,
+ GHOSTTY_ACTION_REDO,
GHOSTTY_ACTION_CHECK_FOR_UPDATES
} ghostty_action_tag_e;
@@ -771,13 +815,15 @@ void ghostty_app_set_color_scheme(ghostty_app_t, ghostty_color_scheme_e);
ghostty_surface_config_s ghostty_surface_config_new();
-ghostty_surface_t ghostty_surface_new(ghostty_app_t, ghostty_surface_config_s*);
+ghostty_surface_t ghostty_surface_new(ghostty_app_t,
+ const ghostty_surface_config_s*);
void ghostty_surface_free(ghostty_surface_t);
void* ghostty_surface_userdata(ghostty_surface_t);
ghostty_app_t ghostty_surface_app(ghostty_surface_t);
ghostty_surface_config_s ghostty_surface_inherited_config(ghostty_surface_t);
void ghostty_surface_update_config(ghostty_surface_t, ghostty_config_t);
bool ghostty_surface_needs_confirm_quit(ghostty_surface_t);
+bool ghostty_surface_process_exited(ghostty_surface_t);
void ghostty_surface_refresh(ghostty_surface_t);
void ghostty_surface_draw(ghostty_surface_t);
void ghostty_surface_set_content_scale(ghostty_surface_t, double, double);
@@ -823,16 +869,16 @@ void ghostty_surface_complete_clipboard_request(ghostty_surface_t,
void*,
bool);
bool ghostty_surface_has_selection(ghostty_surface_t);
-uintptr_t ghostty_surface_selection(ghostty_surface_t, char*, uintptr_t);
+bool ghostty_surface_read_selection(ghostty_surface_t, ghostty_text_s*);
+bool ghostty_surface_read_text(ghostty_surface_t,
+ ghostty_selection_s,
+ ghostty_text_s*);
+void ghostty_surface_free_text(ghostty_surface_t, ghostty_text_s*);
#ifdef __APPLE__
void ghostty_surface_set_display_id(ghostty_surface_t, uint32_t);
void* ghostty_surface_quicklook_font(ghostty_surface_t);
-uintptr_t ghostty_surface_quicklook_word(ghostty_surface_t,
- char*,
- uintptr_t,
- ghostty_selection_s*);
-bool ghostty_surface_selection_info(ghostty_surface_t, ghostty_selection_s*);
+bool ghostty_surface_quicklook_word(ghostty_surface_t, ghostty_text_s*);
#endif
ghostty_inspector_t ghostty_surface_inspector(ghostty_surface_t);
diff --git a/macos/Assets.xcassets/AppIcon.appiconset/Contents.json b/macos/Assets.xcassets/AppIcon.appiconset/Contents.json
deleted file mode 100644
index 9c6bc2e81..000000000
--- a/macos/Assets.xcassets/AppIcon.appiconset/Contents.json
+++ /dev/null
@@ -1,74 +0,0 @@
-{
- "images" : [
- {
- "filename" : "macOS-AppIcon-1024px.png",
- "idiom" : "universal",
- "platform" : "ios",
- "size" : "1024x1024"
- },
- {
- "filename" : "macOS-AppIcon-16px-16pt@1x.png",
- "idiom" : "mac",
- "scale" : "1x",
- "size" : "16x16"
- },
- {
- "filename" : "macOS-AppIcon-32px-16pt@2x.png",
- "idiom" : "mac",
- "scale" : "2x",
- "size" : "16x16"
- },
- {
- "filename" : "macOS-AppIcon-32px-32pt@1x.png",
- "idiom" : "mac",
- "scale" : "1x",
- "size" : "32x32"
- },
- {
- "filename" : "macOS-AppIcon-64px-32pt@2x.png",
- "idiom" : "mac",
- "scale" : "2x",
- "size" : "32x32"
- },
- {
- "filename" : "macOS-AppIcon-128px-128pt@1x.png",
- "idiom" : "mac",
- "scale" : "1x",
- "size" : "128x128"
- },
- {
- "filename" : "macOS-AppIcon-256px-128pt@2x.png",
- "idiom" : "mac",
- "scale" : "2x",
- "size" : "128x128"
- },
- {
- "filename" : "macOS-AppIcon-256px-128pt@2x 1.png",
- "idiom" : "mac",
- "scale" : "1x",
- "size" : "256x256"
- },
- {
- "filename" : "macOS-AppIcon-512px-256pt@2x.png",
- "idiom" : "mac",
- "scale" : "2x",
- "size" : "256x256"
- },
- {
- "filename" : "macOS-AppIcon-512px.png",
- "idiom" : "mac",
- "scale" : "1x",
- "size" : "512x512"
- },
- {
- "filename" : "macOS-AppIcon-1024px 1.png",
- "idiom" : "mac",
- "scale" : "2x",
- "size" : "512x512"
- }
- ],
- "info" : {
- "author" : "xcode",
- "version" : 1
- }
-}
diff --git a/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-1024px 1.png b/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-1024px 1.png
deleted file mode 100644
index a0b716c87..000000000
--- a/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-1024px 1.png
+++ /dev/null
Binary files differ
diff --git a/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-1024px.png b/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-1024px.png
deleted file mode 100644
index a0b716c87..000000000
--- a/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-1024px.png
+++ /dev/null
Binary files differ
diff --git a/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-128px-128pt@1x.png b/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-128px-128pt@1x.png
deleted file mode 100644
index bad0eb891..000000000
--- a/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-128px-128pt@1x.png
+++ /dev/null
Binary files differ
diff --git a/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-16px-16pt@1x.png b/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-16px-16pt@1x.png
deleted file mode 100644
index cacff7a54..000000000
--- a/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-16px-16pt@1x.png
+++ /dev/null
Binary files differ
diff --git a/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-256px-128pt@2x 1.png b/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-256px-128pt@2x 1.png
deleted file mode 100644
index 46c3f7050..000000000
--- a/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-256px-128pt@2x 1.png
+++ /dev/null
Binary files differ
diff --git a/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-256px-128pt@2x.png b/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-256px-128pt@2x.png
deleted file mode 100644
index 46c3f7050..000000000
--- a/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-256px-128pt@2x.png
+++ /dev/null
Binary files differ
diff --git a/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-32px-16pt@2x.png b/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-32px-16pt@2x.png
deleted file mode 100644
index c8011a605..000000000
--- a/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-32px-16pt@2x.png
+++ /dev/null
Binary files differ
diff --git a/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-32px-32pt@1x.png b/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-32px-32pt@1x.png
deleted file mode 100644
index 5e68d5fd0..000000000
--- a/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-32px-32pt@1x.png
+++ /dev/null
Binary files differ
diff --git a/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-512px-256pt@2x.png b/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-512px-256pt@2x.png
deleted file mode 100644
index b51b8d7dc..000000000
--- a/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-512px-256pt@2x.png
+++ /dev/null
Binary files differ
diff --git a/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-512px.png b/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-512px.png
deleted file mode 100644
index f302b40bb..000000000
--- a/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-512px.png
+++ /dev/null
Binary files differ
diff --git a/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-64px-32pt@2x.png b/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-64px-32pt@2x.png
deleted file mode 100644
index e394a5170..000000000
--- a/macos/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-64px-32pt@2x.png
+++ /dev/null
Binary files differ
diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj
index a34c4685f..cf806c7bd 100644
--- a/macos/Ghostty.xcodeproj/project.pbxproj
+++ b/macos/Ghostty.xcodeproj/project.pbxproj
@@ -12,10 +12,18 @@
552964E62B34A9B400030505 /* vim in Resources */ = {isa = PBXBuildFile; fileRef = 552964E52B34A9B400030505 /* vim */; };
857F63812A5E64F200CA4815 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 857F63802A5E64F200CA4815 /* MainMenu.xib */; };
9351BE8E3D22937F003B3499 /* nvim in Resources */ = {isa = PBXBuildFile; fileRef = 9351BE8E2D22937F003B3499 /* nvim */; };
+ A50297352DFA0F3400B4E924 /* Double+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50297342DFA0F3300B4E924 /* Double+Extension.swift */; };
+ A511940F2E050595007258CC /* CloseTerminalIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A511940E2E050590007258CC /* CloseTerminalIntent.swift */; };
+ A51194112E05A483007258CC /* QuickTerminalIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51194102E05A480007258CC /* QuickTerminalIntent.swift */; };
+ A51194132E05D006007258CC /* Optional+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51194122E05D003007258CC /* Optional+Extension.swift */; };
+ A51194172E05D964007258CC /* PermissionRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51194162E05D95E007258CC /* PermissionRequest.swift */; };
+ A51194192E05DFC4007258CC /* IntentPermission.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51194182E05DFBB007258CC /* IntentPermission.swift */; };
A514C8D62B54A16400493A16 /* Ghostty.Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = A514C8D52B54A16400493A16 /* Ghostty.Config.swift */; };
A514C8D72B54A16400493A16 /* Ghostty.Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = A514C8D52B54A16400493A16 /* Ghostty.Config.swift */; };
A514C8D82B54DC6800493A16 /* Ghostty.App.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */; };
- A51B78472AF4B58B00F3EDB9 /* TerminalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51B78462AF4B58B00F3EDB9 /* TerminalWindow.swift */; };
+ A51544FE2DFB111C009E85D8 /* TitlebarTabsTahoeTerminalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51544FD2DFB1110009E85D8 /* TitlebarTabsTahoeTerminalWindow.swift */; };
+ A51545002DFB112E009E85D8 /* TerminalTabsTitlebarTahoe.xib in Resources */ = {isa = PBXBuildFile; fileRef = A51544FF2DFB112E009E85D8 /* TerminalTabsTitlebarTahoe.xib */; };
+ A51B78472AF4B58B00F3EDB9 /* TitlebarTabsVenturaTerminalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51B78462AF4B58B00F3EDB9 /* TitlebarTabsVenturaTerminalWindow.swift */; };
A51BFC1E2B2FB5CE00E92F16 /* About.xib in Resources */ = {isa = PBXBuildFile; fileRef = A51BFC1D2B2FB5CE00E92F16 /* About.xib */; };
A51BFC202B2FB64F00E92F16 /* AboutController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51BFC1F2B2FB64F00E92F16 /* AboutController.swift */; };
A51BFC222B2FB6B400E92F16 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51BFC212B2FB6B400E92F16 /* AboutView.swift */; };
@@ -50,7 +58,16 @@
A54B0CED2D0CFB7700CBEFF8 /* ColorizedGhosttyIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54B0CEC2D0CFB7300CBEFF8 /* ColorizedGhosttyIcon.swift */; };
A54B0CEF2D0D2E2800CBEFF8 /* ColorizedGhosttyIconImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54B0CEE2D0D2E2400CBEFF8 /* ColorizedGhosttyIconImage.swift */; };
A54D786C2CA7978E001B19B1 /* BaseTerminalController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54D786B2CA79788001B19B1 /* BaseTerminalController.swift */; };
- A55685E029A03A9F004303CE /* AppError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55685DF29A03A9F004303CE /* AppError.swift */; };
+ A553F4062E05E93000257779 /* Optional+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51194122E05D003007258CC /* Optional+Extension.swift */; };
+ A553F4072E05E93D00257779 /* Array+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A586366A2DF0A98900E04A10 /* Array+Extension.swift */; };
+ A553F4132E06EB1600257779 /* Ghostty.icon in Resources */ = {isa = PBXBuildFile; fileRef = A553F4122E06EB1600257779 /* Ghostty.icon */; };
+ A553F4142E06EB1600257779 /* Ghostty.icon in Resources */ = {isa = PBXBuildFile; fileRef = A553F4122E06EB1600257779 /* Ghostty.icon */; };
+ A5593FDF2DF8D57C00B47B10 /* TerminalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5593FDE2DF8D57100B47B10 /* TerminalWindow.swift */; };
+ A5593FE12DF8D74000B47B10 /* HiddenTitlebarTerminalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5593FE02DF8D73400B47B10 /* HiddenTitlebarTerminalWindow.swift */; };
+ A5593FE32DF8D78600B47B10 /* TerminalHiddenTitlebar.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5593FE22DF8D78600B47B10 /* TerminalHiddenTitlebar.xib */; };
+ A5593FE52DF8DE3000B47B10 /* TerminalTabsTitlebarVentura.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5593FE42DF8DE3000B47B10 /* TerminalTabsTitlebarVentura.xib */; };
+ A5593FE72DF927D200B47B10 /* TransparentTitlebarTerminalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5593FE62DF927CC00B47B10 /* TransparentTitlebarTerminalWindow.swift */; };
+ A5593FE92DF927DF00B47B10 /* TerminalTransparentTitlebar.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5593FE82DF927DF00B47B10 /* TerminalTransparentTitlebar.xib */; };
A55B7BB829B6F53A0055DE60 /* Package.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55B7BB729B6F53A0055DE60 /* Package.swift */; };
A55B7BBC29B6FC330055DE60 /* SurfaceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55B7BBB29B6FC330055DE60 /* SurfaceView.swift */; };
A56B880B2A840447007A0E29 /* Carbon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A56B880A2A840447007A0E29 /* Carbon.framework */; };
@@ -59,6 +76,12 @@
A571AB1D2A206FCF00248498 /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A5D495A1299BEC7E00DD1313 /* GhosttyKit.xcframework */; };
A57D79272C9C879B001D522E /* SecureInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = A57D79262C9C8798001D522E /* SecureInput.swift */; };
A586167C2B7703CC009BDB1D /* fish in Resources */ = {isa = PBXBuildFile; fileRef = A586167B2B7703CC009BDB1D /* fish */; };
+ A586365F2DEE6C2300E04A10 /* SplitTree.swift in Sources */ = {isa = PBXBuildFile; fileRef = A586365E2DEE6C2100E04A10 /* SplitTree.swift */; };
+ A58636662DEF964100E04A10 /* TerminalSplitTreeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A58636652DEF963F00E04A10 /* TerminalSplitTreeView.swift */; };
+ A586366B2DF0A98C00E04A10 /* Array+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A586366A2DF0A98900E04A10 /* Array+Extension.swift */; };
+ A586366F2DF25D8600E04A10 /* Duration+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A586366E2DF25D8300E04A10 /* Duration+Extension.swift */; };
+ A58636712DF298FB00E04A10 /* ExpiringUndoManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A58636702DF298F700E04A10 /* ExpiringUndoManager.swift */; };
+ A58636732DF4813400E04A10 /* UndoManager+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A58636722DF4813000E04A10 /* UndoManager+Extension.swift */; };
A5874D992DAD751B00E83852 /* CGS.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5874D982DAD751A00E83852 /* CGS.swift */; };
A5874D9D2DAD786100E83852 /* NSWindow+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5874D9C2DAD785F00E83852 /* NSWindow+Extension.swift */; };
A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59444F629A2ED5200725BBA /* SettingsView.swift */; };
@@ -66,9 +89,6 @@
A596309A2AEE1C6400D64628 /* Terminal.xib in Resources */ = {isa = PBXBuildFile; fileRef = A59630992AEE1C6400D64628 /* Terminal.xib */; };
A596309C2AEE1C9E00D64628 /* TerminalController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A596309B2AEE1C9E00D64628 /* TerminalController.swift */; };
A596309E2AEE1D6C00D64628 /* TerminalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A596309D2AEE1D6C00D64628 /* TerminalView.swift */; };
- A59630A02AEF6AEB00D64628 /* TerminalManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A596309F2AEF6AEB00D64628 /* TerminalManager.swift */; };
- A59630A22AF0415000D64628 /* Ghostty.TerminalSplit.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59630A12AF0415000D64628 /* Ghostty.TerminalSplit.swift */; };
- A59630A42AF059BB00D64628 /* Ghostty.SplitNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59630A32AF059BB00D64628 /* Ghostty.SplitNode.swift */; };
A5985CD72C320C4500C57AD3 /* String+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5985CD62C320C4500C57AD3 /* String+Extension.swift */; };
A5985CD82C320C4500C57AD3 /* String+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5985CD62C320C4500C57AD3 /* String+Extension.swift */; };
A5985CE62C33060F00C57AD3 /* man in Resources */ = {isa = PBXBuildFile; fileRef = A5985CE52C33060F00C57AD3 /* man */; };
@@ -78,9 +98,10 @@
A5A1F8852A489D6800D1E8BC /* terminfo in Resources */ = {isa = PBXBuildFile; fileRef = A5A1F8842A489D6800D1E8BC /* terminfo */; };
A5A2A3CA2D4445E30033CF96 /* Dock.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A2A3C92D4445E20033CF96 /* Dock.swift */; };
A5A2A3CC2D444ABB0033CF96 /* NSApplication+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A2A3CB2D444AB80033CF96 /* NSApplication+Extension.swift */; };
- A5A6F72A2CC41B8900B232A5 /* Xcode.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A6F7292CC41B8700B232A5 /* Xcode.swift */; };
+ A5A6F72A2CC41B8900B232A5 /* AppInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A6F7292CC41B8700B232A5 /* AppInfo.swift */; };
A5AEB1652D5BE7D000513529 /* LastWindowPosition.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5AEB1642D5BE7BF00513529 /* LastWindowPosition.swift */; };
A5B30539299BEAAB0047F10C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5B30538299BEAAB0047F10C /* Assets.xcassets */; };
+ A5B4EA852DFE691B0022C3A2 /* NSMenuItem+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5B4EA842DFE69140022C3A2 /* NSMenuItem+Extension.swift */; };
A5CA378C2D2A4DEB00931030 /* KeyboardLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CA378B2D2A4DE800931030 /* KeyboardLayout.swift */; };
A5CA378E2D31D6C300931030 /* Weak.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CA378D2D31D6C100931030 /* Weak.swift */; };
A5CBD0562C9E65B80017A1AE /* DraggableWindowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */; };
@@ -106,9 +127,20 @@
A5E112932AF73E6E00C6E0C2 /* ClipboardConfirmation.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5E112922AF73E6E00C6E0C2 /* ClipboardConfirmation.xib */; };
A5E112952AF73E8A00C6E0C2 /* ClipboardConfirmationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E112942AF73E8A00C6E0C2 /* ClipboardConfirmationController.swift */; };
A5E112972AF7401B00C6E0C2 /* ClipboardConfirmationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E112962AF7401B00C6E0C2 /* ClipboardConfirmationView.swift */; };
+ A5E4082A2E022E9E0035FEAC /* TabGroupCloseCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408292E022E9B0035FEAC /* TabGroupCloseCoordinator.swift */; };
+ A5E4082E2E0237460035FEAC /* NewTerminalIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E4082D2E0237410035FEAC /* NewTerminalIntent.swift */; };
+ A5E408302E0271320035FEAC /* GhosttyIntentError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E4082F2E0271320035FEAC /* GhosttyIntentError.swift */; };
+ A5E408322E02FEDF0035FEAC /* TerminalEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408312E02FEDC0035FEAC /* TerminalEntity.swift */; };
+ A5E408342E0320140035FEAC /* GetTerminalDetailsIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408332E03200F0035FEAC /* GetTerminalDetailsIntent.swift */; };
+ A5E408382E03C7DA0035FEAC /* Ghostty.Surface.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408372E03C7D80035FEAC /* Ghostty.Surface.swift */; };
+ A5E4083A2E0449BD0035FEAC /* Ghostty.Command.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408392E0449BB0035FEAC /* Ghostty.Command.swift */; };
+ A5E4083C2E044DB50035FEAC /* Ghostty.Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E4083B2E044DB40035FEAC /* Ghostty.Error.swift */; };
+ A5E408402E04532C0035FEAC /* CommandEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E4083F2E04532A0035FEAC /* CommandEntity.swift */; };
+ A5E408432E047D0B0035FEAC /* CommandPaletteIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408422E047D060035FEAC /* CommandPaletteIntent.swift */; };
+ A5E408452E0483FD0035FEAC /* KeybindIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408442E0483F80035FEAC /* KeybindIntent.swift */; };
+ A5E408472E04852B0035FEAC /* InputIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408462E0485270035FEAC /* InputIntent.swift */; };
A5FEB3002ABB69450068369E /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FEB2FF2ABB69450068369E /* main.swift */; };
AEE8B3452B9AA39600260C5E /* NSPasteboard+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */; };
- AEF9CE242B6AD07A0017E195 /* TerminalToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEF9CE232B6AD07A0017E195 /* TerminalToolbar.swift */; };
C159E81D2B66A06B00FDFE9C /* OSColor+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */; };
C159E89D2B69A2EF00FDFE9C /* OSColor+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */; };
C1F26EA72B738B9900404083 /* NSView+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1F26EA62B738B9900404083 /* NSView+Extension.swift */; };
@@ -125,8 +157,16 @@
552964E52B34A9B400030505 /* vim */ = {isa = PBXFileReference; lastKnownFileType = folder; name = vim; path = "../zig-out/share/vim"; sourceTree = "<group>"; };
857F63802A5E64F200CA4815 /* MainMenu.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = MainMenu.xib; sourceTree = "<group>"; };
9351BE8E2D22937F003B3499 /* nvim */ = {isa = PBXFileReference; lastKnownFileType = folder; name = nvim; path = "../zig-out/share/nvim"; sourceTree = "<group>"; };
+ A50297342DFA0F3300B4E924 /* Double+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Double+Extension.swift"; sourceTree = "<group>"; };
+ A511940E2E050590007258CC /* CloseTerminalIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseTerminalIntent.swift; sourceTree = "<group>"; };
+ A51194102E05A480007258CC /* QuickTerminalIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickTerminalIntent.swift; sourceTree = "<group>"; };
+ A51194122E05D003007258CC /* Optional+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Optional+Extension.swift"; sourceTree = "<group>"; };
+ A51194162E05D95E007258CC /* PermissionRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionRequest.swift; sourceTree = "<group>"; };
+ A51194182E05DFBB007258CC /* IntentPermission.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntentPermission.swift; sourceTree = "<group>"; };
A514C8D52B54A16400493A16 /* Ghostty.Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Config.swift; sourceTree = "<group>"; };
- A51B78462AF4B58B00F3EDB9 /* TerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalWindow.swift; sourceTree = "<group>"; };
+ A51544FD2DFB1110009E85D8 /* TitlebarTabsTahoeTerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TitlebarTabsTahoeTerminalWindow.swift; sourceTree = "<group>"; };
+ A51544FF2DFB112E009E85D8 /* TerminalTabsTitlebarTahoe.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TerminalTabsTitlebarTahoe.xib; sourceTree = "<group>"; };
+ A51B78462AF4B58B00F3EDB9 /* TitlebarTabsVenturaTerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TitlebarTabsVenturaTerminalWindow.swift; sourceTree = "<group>"; };
A51BFC1D2B2FB5CE00E92F16 /* About.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = About.xib; sourceTree = "<group>"; };
A51BFC1F2B2FB64F00E92F16 /* AboutController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutController.swift; sourceTree = "<group>"; };
A51BFC212B2FB6B400E92F16 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = "<group>"; };
@@ -155,7 +195,13 @@
A54B0CEC2D0CFB7300CBEFF8 /* ColorizedGhosttyIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorizedGhosttyIcon.swift; sourceTree = "<group>"; };
A54B0CEE2D0D2E2400CBEFF8 /* ColorizedGhosttyIconImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorizedGhosttyIconImage.swift; sourceTree = "<group>"; };
A54D786B2CA79788001B19B1 /* BaseTerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseTerminalController.swift; sourceTree = "<group>"; };
- A55685DF29A03A9F004303CE /* AppError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppError.swift; sourceTree = "<group>"; };
+ A553F4122E06EB1600257779 /* Ghostty.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; name = Ghostty.icon; path = ../images/Ghostty.icon; sourceTree = SOURCE_ROOT; };
+ A5593FDE2DF8D57100B47B10 /* TerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalWindow.swift; sourceTree = "<group>"; };
+ A5593FE02DF8D73400B47B10 /* HiddenTitlebarTerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HiddenTitlebarTerminalWindow.swift; sourceTree = "<group>"; };
+ A5593FE22DF8D78600B47B10 /* TerminalHiddenTitlebar.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TerminalHiddenTitlebar.xib; sourceTree = "<group>"; };
+ A5593FE42DF8DE3000B47B10 /* TerminalTabsTitlebarVentura.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TerminalTabsTitlebarVentura.xib; sourceTree = "<group>"; };
+ A5593FE62DF927CC00B47B10 /* TransparentTitlebarTerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransparentTitlebarTerminalWindow.swift; sourceTree = "<group>"; };
+ A5593FE82DF927DF00B47B10 /* TerminalTransparentTitlebar.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TerminalTransparentTitlebar.xib; sourceTree = "<group>"; };
A55B7BB729B6F53A0055DE60 /* Package.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Package.swift; sourceTree = "<group>"; };
A55B7BBB29B6FC330055DE60 /* SurfaceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceView.swift; sourceTree = "<group>"; };
A56B880A2A840447007A0E29 /* Carbon.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Carbon.framework; path = System/Library/Frameworks/Carbon.framework; sourceTree = SDKROOT; };
@@ -164,6 +210,12 @@
A571AB1C2A206FC600248498 /* Ghostty-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "Ghostty-Info.plist"; sourceTree = "<group>"; };
A57D79262C9C8798001D522E /* SecureInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureInput.swift; sourceTree = "<group>"; };
A586167B2B7703CC009BDB1D /* fish */ = {isa = PBXFileReference; lastKnownFileType = folder; name = fish; path = "../zig-out/share/fish"; sourceTree = "<group>"; };
+ A586365E2DEE6C2100E04A10 /* SplitTree.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitTree.swift; sourceTree = "<group>"; };
+ A58636652DEF963F00E04A10 /* TerminalSplitTreeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSplitTreeView.swift; sourceTree = "<group>"; };
+ A586366A2DF0A98900E04A10 /* Array+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+Extension.swift"; sourceTree = "<group>"; };
+ A586366E2DF25D8300E04A10 /* Duration+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Duration+Extension.swift"; sourceTree = "<group>"; };
+ A58636702DF298F700E04A10 /* ExpiringUndoManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpiringUndoManager.swift; sourceTree = "<group>"; };
+ A58636722DF4813000E04A10 /* UndoManager+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UndoManager+Extension.swift"; sourceTree = "<group>"; };
A5874D982DAD751A00E83852 /* CGS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGS.swift; sourceTree = "<group>"; };
A5874D9C2DAD785F00E83852 /* NSWindow+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSWindow+Extension.swift"; sourceTree = "<group>"; };
A59444F629A2ED5200725BBA /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
@@ -171,9 +223,6 @@
A59630992AEE1C6400D64628 /* Terminal.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = Terminal.xib; sourceTree = "<group>"; };
A596309B2AEE1C9E00D64628 /* TerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalController.swift; sourceTree = "<group>"; };
A596309D2AEE1D6C00D64628 /* TerminalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalView.swift; sourceTree = "<group>"; };
- A596309F2AEF6AEB00D64628 /* TerminalManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalManager.swift; sourceTree = "<group>"; };
- A59630A12AF0415000D64628 /* Ghostty.TerminalSplit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.TerminalSplit.swift; sourceTree = "<group>"; };
- A59630A32AF059BB00D64628 /* Ghostty.SplitNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.SplitNode.swift; sourceTree = "<group>"; };
A5985CD62C320C4500C57AD3 /* String+Extension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "String+Extension.swift"; sourceTree = "<group>"; };
A5985CE52C33060F00C57AD3 /* man */ = {isa = PBXFileReference; lastKnownFileType = folder; name = man; path = "../zig-out/share/man"; sourceTree = "<group>"; };
A599CDAF2CF103F20049FA26 /* NSAppearance+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSAppearance+Extension.swift"; sourceTree = "<group>"; };
@@ -182,11 +231,12 @@
A5A1F8842A489D6800D1E8BC /* terminfo */ = {isa = PBXFileReference; lastKnownFileType = folder; name = terminfo; path = "../zig-out/share/terminfo"; sourceTree = "<group>"; };
A5A2A3C92D4445E20033CF96 /* Dock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Dock.swift; sourceTree = "<group>"; };
A5A2A3CB2D444AB80033CF96 /* NSApplication+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSApplication+Extension.swift"; sourceTree = "<group>"; };
- A5A6F7292CC41B8700B232A5 /* Xcode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Xcode.swift; sourceTree = "<group>"; };
+ A5A6F7292CC41B8700B232A5 /* AppInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppInfo.swift; sourceTree = "<group>"; };
A5AEB1642D5BE7BF00513529 /* LastWindowPosition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LastWindowPosition.swift; sourceTree = "<group>"; };
A5B30531299BEAAA0047F10C /* Ghostty.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Ghostty.app; sourceTree = BUILT_PRODUCTS_DIR; };
A5B30538299BEAAB0047F10C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
A5B3053D299BEAAB0047F10C /* Ghostty.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Ghostty.entitlements; sourceTree = "<group>"; };
+ A5B4EA842DFE69140022C3A2 /* NSMenuItem+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSMenuItem+Extension.swift"; sourceTree = "<group>"; };
A5CA378B2D2A4DE800931030 /* KeyboardLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardLayout.swift; sourceTree = "<group>"; };
A5CA378D2D31D6C100931030 /* Weak.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Weak.swift; sourceTree = "<group>"; };
A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraggableWindowView.swift; sourceTree = "<group>"; };
@@ -213,9 +263,20 @@
A5E112922AF73E6E00C6E0C2 /* ClipboardConfirmation.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ClipboardConfirmation.xib; sourceTree = "<group>"; };
A5E112942AF73E8A00C6E0C2 /* ClipboardConfirmationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardConfirmationController.swift; sourceTree = "<group>"; };
A5E112962AF7401B00C6E0C2 /* ClipboardConfirmationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardConfirmationView.swift; sourceTree = "<group>"; };
+ A5E408292E022E9B0035FEAC /* TabGroupCloseCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabGroupCloseCoordinator.swift; sourceTree = "<group>"; };
+ A5E4082D2E0237410035FEAC /* NewTerminalIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewTerminalIntent.swift; sourceTree = "<group>"; };
+ A5E4082F2E0271320035FEAC /* GhosttyIntentError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyIntentError.swift; sourceTree = "<group>"; };
+ A5E408312E02FEDC0035FEAC /* TerminalEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalEntity.swift; sourceTree = "<group>"; };
+ A5E408332E03200F0035FEAC /* GetTerminalDetailsIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetTerminalDetailsIntent.swift; sourceTree = "<group>"; };
+ A5E408372E03C7D80035FEAC /* Ghostty.Surface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Surface.swift; sourceTree = "<group>"; };
+ A5E408392E0449BB0035FEAC /* Ghostty.Command.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Command.swift; sourceTree = "<group>"; };
+ A5E4083B2E044DB40035FEAC /* Ghostty.Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Error.swift; sourceTree = "<group>"; };
+ A5E4083F2E04532A0035FEAC /* CommandEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandEntity.swift; sourceTree = "<group>"; };
+ A5E408422E047D060035FEAC /* CommandPaletteIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteIntent.swift; sourceTree = "<group>"; };
+ A5E408442E0483F80035FEAC /* KeybindIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeybindIntent.swift; sourceTree = "<group>"; };
+ A5E408462E0485270035FEAC /* InputIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputIntent.swift; sourceTree = "<group>"; };
A5FEB2FF2ABB69450068369E /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = "<group>"; };
AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSPasteboard+Extension.swift"; sourceTree = "<group>"; };
- AEF9CE232B6AD07A0017E195 /* TerminalToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalToolbar.swift; sourceTree = "<group>"; };
C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OSColor+Extension.swift"; sourceTree = "<group>"; };
C1F26EA62B738B9900404083 /* NSView+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSView+Extension.swift"; sourceTree = "<group>"; };
C1F26EE72B76CBFC00404083 /* VibrantLayer.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = VibrantLayer.h; sourceTree = "<group>"; };
@@ -273,8 +334,10 @@
A56D58872ACDE6BE00508D2C /* Services */,
A59630982AEE1C4400D64628 /* Terminal */,
A5CBD05A2CA0C5910017A1AE /* QuickTerminal */,
+ A5E4082C2E0237270035FEAC /* App Intents */,
A5E112912AF73E4D00C6E0C2 /* ClipboardConfirmation */,
A57D79252C9C8782001D522E /* Secure Input */,
+ A58636622DEF955100E04A10 /* Splits */,
A53A29742DB2E04900B6E02C /* Command Palette */,
A534263E2A7DCC5800EBB7A2 /* Settings */,
A51BFC1C2B2FB5AB00E92F16 /* About */,
@@ -287,34 +350,25 @@
A534263D2A7DCBB000EBB7A2 /* Helpers */ = {
isa = PBXGroup;
children = (
+ A58636692DF0A98100E04A10 /* Extensions */,
A5874D9B2DAD781100E83852 /* Private */,
+ A5A6F7292CC41B8700B232A5 /* AppInfo.swift */,
A5AEB1642D5BE7BF00513529 /* LastWindowPosition.swift */,
- A5A6F7292CC41B8700B232A5 /* Xcode.swift */,
A5CEAFFE29C2410700646FDA /* Backport.swift */,
A5333E1B2B5A1CE3008AEFF7 /* CrossKit.swift */,
A5CBD0572C9F30860017A1AE /* Cursor.swift */,
A5D0AF3C2B37804400D21823 /* CodableBridge.swift */,
+ A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */,
+ A58636702DF298F700E04A10 /* ExpiringUndoManager.swift */,
A52FFF582CAA4FF1000C6A5B /* Fullscreen.swift */,
A59630962AEE163600D64628 /* HostingWindow.swift */,
A5CA378B2D2A4DE800931030 /* KeyboardLayout.swift */,
- A53A29802DB44A5E00B6E02C /* KeyboardShortcut+Extension.swift */,
A59FB5D02AE0DEA7009128F3 /* MetalView.swift */,
- A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */,
- A53A297E2DB4480A00B6E02C /* EventModifiers+Extension.swift */,
- C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */,
- A599CDAF2CF103F20049FA26 /* NSAppearance+Extension.swift */,
- A5A2A3CB2D444AB80033CF96 /* NSApplication+Extension.swift */,
- A54B0CEA2D0CFB4A00CBEFF8 /* NSImage+Extension.swift */,
- A52FFF5C2CAB4D05000C6A5B /* NSScreen+Extension.swift */,
- C1F26EA62B738B9900404083 /* NSView+Extension.swift */,
- AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */,
- A5874D9C2DAD785F00E83852 /* NSWindow+Extension.swift */,
- A5985CD62C320C4500C57AD3 /* String+Extension.swift */,
- A5CC36142C9CDA03004D6760 /* View+Extension.swift */,
+ A51194162E05D95E007258CC /* PermissionRequest.swift */,
+ A5E408292E022E9B0035FEAC /* TabGroupCloseCoordinator.swift */,
A5CA378D2D31D6C100931030 /* Weak.swift */,
C1F26EE72B76CBFC00404083 /* VibrantLayer.h */,
C1F26EE82B76CBFC00404083 /* VibrantLayer.m */,
- A5CEAFDA29B8005900646FDA /* SplitView */,
);
path = Helpers;
sourceTree = "<group>";
@@ -388,6 +442,23 @@
path = Sources;
sourceTree = "<group>";
};
+ A5593FDD2DF8D56000B47B10 /* Window Styles */ = {
+ isa = PBXGroup;
+ children = (
+ A59630992AEE1C6400D64628 /* Terminal.xib */,
+ A5593FE22DF8D78600B47B10 /* TerminalHiddenTitlebar.xib */,
+ A51544FF2DFB112E009E85D8 /* TerminalTabsTitlebarTahoe.xib */,
+ A5593FE42DF8DE3000B47B10 /* TerminalTabsTitlebarVentura.xib */,
+ A5593FE82DF927DF00B47B10 /* TerminalTransparentTitlebar.xib */,
+ A5593FDE2DF8D57100B47B10 /* TerminalWindow.swift */,
+ A5593FE02DF8D73400B47B10 /* HiddenTitlebarTerminalWindow.swift */,
+ A51B78462AF4B58B00F3EDB9 /* TitlebarTabsVenturaTerminalWindow.swift */,
+ A51544FD2DFB1110009E85D8 /* TitlebarTabsTahoeTerminalWindow.swift */,
+ A5593FE62DF927CC00B47B10 /* TransparentTitlebarTerminalWindow.swift */,
+ );
+ path = "Window Styles";
+ sourceTree = "<group>";
+ };
A55B7BB429B6F4410055DE60 /* Ghostty */ = {
isa = PBXGroup;
children = (
@@ -397,14 +468,14 @@
A5333E152B59DE8E008AEFF7 /* SurfaceView_UIKit.swift */,
A59FB5CE2AE0DB50009128F3 /* InspectorView.swift */,
A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */,
+ A5E408392E0449BB0035FEAC /* Ghostty.Command.swift */,
A514C8D52B54A16400493A16 /* Ghostty.Config.swift */,
A53A6C022CCC1B7D00943E98 /* Ghostty.Action.swift */,
+ A5E4083B2E044DB40035FEAC /* Ghostty.Error.swift */,
A5CF66D62D29DDB100139794 /* Ghostty.Event.swift */,
A5278A9A2AA05B2600CD3039 /* Ghostty.Input.swift */,
A56D58852ACDDB4100508D2C /* Ghostty.Shell.swift */,
- A59630A32AF059BB00D64628 /* Ghostty.SplitNode.swift */,
- A59630A12AF0415000D64628 /* Ghostty.TerminalSplit.swift */,
- A55685DF29A03A9F004303CE /* AppError.swift */,
+ A5E408372E03C7D80035FEAC /* Ghostty.Surface.swift */,
A52FFF5A2CAA54A8000C6A5B /* FullscreenMode+Extension.swift */,
A5CF66D32D289CEA00139794 /* NSEvent+Extension.swift */,
);
@@ -428,6 +499,42 @@
path = "Secure Input";
sourceTree = "<group>";
};
+ A58636622DEF955100E04A10 /* Splits */ = {
+ isa = PBXGroup;
+ children = (
+ A586365E2DEE6C2100E04A10 /* SplitTree.swift */,
+ A58636652DEF963F00E04A10 /* TerminalSplitTreeView.swift */,
+ A5CEAFDB29B8009000646FDA /* SplitView.swift */,
+ A5CEAFDD29B8058B00646FDA /* SplitView.Divider.swift */,
+ );
+ path = Splits;
+ sourceTree = "<group>";
+ };
+ A58636692DF0A98100E04A10 /* Extensions */ = {
+ isa = PBXGroup;
+ children = (
+ A586366A2DF0A98900E04A10 /* Array+Extension.swift */,
+ A50297342DFA0F3300B4E924 /* Double+Extension.swift */,
+ A586366E2DF25D8300E04A10 /* Duration+Extension.swift */,
+ A53A29802DB44A5E00B6E02C /* KeyboardShortcut+Extension.swift */,
+ A53A297E2DB4480A00B6E02C /* EventModifiers+Extension.swift */,
+ A51194122E05D003007258CC /* Optional+Extension.swift */,
+ C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */,
+ A599CDAF2CF103F20049FA26 /* NSAppearance+Extension.swift */,
+ A5A2A3CB2D444AB80033CF96 /* NSApplication+Extension.swift */,
+ A54B0CEA2D0CFB4A00CBEFF8 /* NSImage+Extension.swift */,
+ A5B4EA842DFE69140022C3A2 /* NSMenuItem+Extension.swift */,
+ A52FFF5C2CAB4D05000C6A5B /* NSScreen+Extension.swift */,
+ AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */,
+ C1F26EA62B738B9900404083 /* NSView+Extension.swift */,
+ A5874D9C2DAD785F00E83852 /* NSWindow+Extension.swift */,
+ A5985CD62C320C4500C57AD3 /* String+Extension.swift */,
+ A58636722DF4813000E04A10 /* UndoManager+Extension.swift */,
+ A5CC36142C9CDA03004D6760 /* View+Extension.swift */,
+ );
+ path = Extensions;
+ sourceTree = "<group>";
+ };
A5874D9B2DAD781100E83852 /* Private */ = {
isa = PBXGroup;
children = (
@@ -440,13 +547,10 @@
A59630982AEE1C4400D64628 /* Terminal */ = {
isa = PBXGroup;
children = (
- A59630992AEE1C6400D64628 /* Terminal.xib */,
- A596309F2AEF6AEB00D64628 /* TerminalManager.swift */,
+ A5593FDD2DF8D56000B47B10 /* Window Styles */,
A596309B2AEE1C9E00D64628 /* TerminalController.swift */,
A5D0AF3A2B36A1DE00D21823 /* TerminalRestorable.swift */,
A596309D2AEE1D6C00D64628 /* TerminalView.swift */,
- A51B78462AF4B58B00F3EDB9 /* TerminalWindow.swift */,
- AEF9CE232B6AD07A0017E195 /* TerminalToolbar.swift */,
A535B9D9299C569B0017E2E4 /* ErrorView.swift */,
A54D786B2CA79788001B19B1 /* BaseTerminalController.swift */,
);
@@ -475,6 +579,7 @@
children = (
A571AB1C2A206FC600248498 /* Ghostty-Info.plist */,
A5B30538299BEAAB0047F10C /* Assets.xcassets */,
+ A553F4122E06EB1600257779 /* Ghostty.icon */,
A5B3053D299BEAAB0047F10C /* Ghostty.entitlements */,
A51BFC282B30F26D00E92F16 /* GhosttyDebug.entitlements */,
3B39CAA42B33949B00DABEB8 /* GhosttyReleaseLocal.entitlements */,
@@ -515,15 +620,6 @@
path = "Global Keybinds";
sourceTree = "<group>";
};
- A5CEAFDA29B8005900646FDA /* SplitView */ = {
- isa = PBXGroup;
- children = (
- A5CEAFDB29B8009000646FDA /* SplitView.swift */,
- A5CEAFDD29B8058B00646FDA /* SplitView.Divider.swift */,
- );
- path = SplitView;
- sourceTree = "<group>";
- };
A5D495A3299BECBA00DD1313 /* Frameworks */ = {
isa = PBXGroup;
children = (
@@ -543,6 +639,32 @@
path = ClipboardConfirmation;
sourceTree = "<group>";
};
+ A5E4082C2E0237270035FEAC /* App Intents */ = {
+ isa = PBXGroup;
+ children = (
+ A5E408412E0453370035FEAC /* Entities */,
+ A511940E2E050590007258CC /* CloseTerminalIntent.swift */,
+ A5E4082D2E0237410035FEAC /* NewTerminalIntent.swift */,
+ A5E408332E03200F0035FEAC /* GetTerminalDetailsIntent.swift */,
+ A51194102E05A480007258CC /* QuickTerminalIntent.swift */,
+ A5E408422E047D060035FEAC /* CommandPaletteIntent.swift */,
+ A5E408462E0485270035FEAC /* InputIntent.swift */,
+ A5E408442E0483F80035FEAC /* KeybindIntent.swift */,
+ A5E4082F2E0271320035FEAC /* GhosttyIntentError.swift */,
+ A51194182E05DFBB007258CC /* IntentPermission.swift */,
+ );
+ path = "App Intents";
+ sourceTree = "<group>";
+ };
+ A5E408412E0453370035FEAC /* Entities */ = {
+ isa = PBXGroup;
+ children = (
+ A5E408312E02FEDC0035FEAC /* TerminalEntity.swift */,
+ A5E4083F2E04532A0035FEAC /* CommandEntity.swift */,
+ );
+ path = Entities;
+ sourceTree = "<group>";
+ };
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@@ -630,9 +752,12 @@
buildActionMask = 2147483647;
files = (
FC9ABA9C2D0F53F80020D4C8 /* bash-completion in Resources */,
+ A553F4142E06EB1600257779 /* Ghostty.icon in Resources */,
+ A5593FE52DF8DE3000B47B10 /* TerminalTabsTitlebarVentura.xib in Resources */,
29C15B1D2CDC3B2900520DD4 /* bat in Resources */,
A586167C2B7703CC009BDB1D /* fish in Resources */,
55154BE02B33911F001622DC /* ghostty in Resources */,
+ A5593FE32DF8D78600B47B10 /* TerminalHiddenTitlebar.xib in Resources */,
A546F1142D7B68D7003B11A0 /* locale in Resources */,
A5985CE62C33060F00C57AD3 /* man in Resources */,
9351BE8E3D22937F003B3499 /* nvim in Resources */,
@@ -641,10 +766,12 @@
FC5218FA2D10FFCE004C93E0 /* zsh in Resources */,
A5B30539299BEAAB0047F10C /* Assets.xcassets in Resources */,
A51BFC1E2B2FB5CE00E92F16 /* About.xib in Resources */,
+ A5593FE92DF927DF00B47B10 /* TerminalTransparentTitlebar.xib in Resources */,
A5E112932AF73E6E00C6E0C2 /* ClipboardConfirmation.xib in Resources */,
A5CDF1912AAF9A5800513312 /* ConfigurationErrors.xib in Resources */,
857F63812A5E64F200CA4815 /* MainMenu.xib in Resources */,
A596309A2AEE1C6400D64628 /* Terminal.xib in Resources */,
+ A51545002DFB112E009E85D8 /* TerminalTabsTitlebarTahoe.xib in Resources */,
A5CBD05C2CA0C5C70017A1AE /* QuickTerminal.xib in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -654,6 +781,7 @@
buildActionMask = 2147483647;
files = (
A53D0C952B53B4D800305CE6 /* Assets.xcassets in Resources */,
+ A553F4132E06EB1600257779 /* Ghostty.icon in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -665,77 +793,101 @@
buildActionMask = 2147483647;
files = (
A5AEB1652D5BE7D000513529 /* LastWindowPosition.swift in Sources */,
- A59630A42AF059BB00D64628 /* Ghostty.SplitNode.swift in Sources */,
+ A5E408432E047D0B0035FEAC /* CommandPaletteIntent.swift in Sources */,
A514C8D62B54A16400493A16 /* Ghostty.Config.swift in Sources */,
A54B0CEB2D0CFB4C00CBEFF8 /* NSImage+Extension.swift in Sources */,
A5874D9D2DAD786100E83852 /* NSWindow+Extension.swift in Sources */,
A54D786C2CA7978E001B19B1 /* BaseTerminalController.swift in Sources */,
+ A58636732DF4813400E04A10 /* UndoManager+Extension.swift in Sources */,
A59FB5CF2AE0DB50009128F3 /* InspectorView.swift in Sources */,
CFBB5FEA2D231E5000FD62EE /* QuickTerminalSpaceBehavior.swift in Sources */,
A54B0CE92D0CECD100CBEFF8 /* ColorizedGhosttyIconView.swift in Sources */,
A5D0AF3D2B37804400D21823 /* CodableBridge.swift in Sources */,
+ A51194132E05D006007258CC /* Optional+Extension.swift in Sources */,
A5D0AF3B2B36A1DE00D21823 /* TerminalRestorable.swift in Sources */,
C1F26EA72B738B9900404083 /* NSView+Extension.swift in Sources */,
+ A586366F2DF25D8600E04A10 /* Duration+Extension.swift in Sources */,
A5CF66D42D289CEE00139794 /* NSEvent+Extension.swift in Sources */,
+ A5E408342E0320140035FEAC /* GetTerminalDetailsIntent.swift in Sources */,
A5CBD0642CA122E70017A1AE /* QuickTerminalPosition.swift in Sources */,
A596309C2AEE1C9E00D64628 /* TerminalController.swift in Sources */,
+ A5E408322E02FEDF0035FEAC /* TerminalEntity.swift in Sources */,
A5CC36152C9CDA06004D6760 /* View+Extension.swift in Sources */,
A56D58892ACDE6CA00508D2C /* ServiceProvider.swift in Sources */,
A5CBD0602CA0C90A0017A1AE /* QuickTerminalWindow.swift in Sources */,
A5CBD05E2CA0C5EC0017A1AE /* QuickTerminalController.swift in Sources */,
A5CF66D72D29DDB500139794 /* Ghostty.Event.swift in Sources */,
+ A511940F2E050595007258CC /* CloseTerminalIntent.swift in Sources */,
+ A5E408382E03C7DA0035FEAC /* Ghostty.Surface.swift in Sources */,
+ A5593FE72DF927D200B47B10 /* TransparentTitlebarTerminalWindow.swift in Sources */,
A5A2A3CA2D4445E30033CF96 /* Dock.swift in Sources */,
+ A586365F2DEE6C2300E04A10 /* SplitTree.swift in Sources */,
A51BFC222B2FB6B400E92F16 /* AboutView.swift in Sources */,
A5278A9B2AA05B2600CD3039 /* Ghostty.Input.swift in Sources */,
A53A29812DB44A6100B6E02C /* KeyboardShortcut+Extension.swift in Sources */,
+ A50297352DFA0F3400B4E924 /* Double+Extension.swift in Sources */,
A5CBD0562C9E65B80017A1AE /* DraggableWindowView.swift in Sources */,
+ A51194112E05A483007258CC /* QuickTerminalIntent.swift in Sources */,
C1F26EE92B76CBFC00404083 /* VibrantLayer.m in Sources */,
+ A5593FDF2DF8D57C00B47B10 /* TerminalWindow.swift in Sources */,
+ A58636712DF298FB00E04A10 /* ExpiringUndoManager.swift in Sources */,
A59630972AEE163600D64628 /* HostingWindow.swift in Sources */,
- A59630A02AEF6AEB00D64628 /* TerminalManager.swift in Sources */,
A51BFC2B2B30F6BE00E92F16 /* UpdateDelegate.swift in Sources */,
A5CBD06B2CA322430017A1AE /* GlobalEventTap.swift in Sources */,
AEE8B3452B9AA39600260C5E /* NSPasteboard+Extension.swift in Sources */,
+ A51194172E05D964007258CC /* PermissionRequest.swift in Sources */,
+ A51194192E05DFC4007258CC /* IntentPermission.swift in Sources */,
A52FFF5D2CAB4D08000C6A5B /* NSScreen+Extension.swift in Sources */,
A53426352A7DA53D00EBB7A2 /* AppDelegate.swift in Sources */,
A5CBD0582C9F30960017A1AE /* Cursor.swift in Sources */,
- A5A6F72A2CC41B8900B232A5 /* Xcode.swift in Sources */,
+ A5A6F72A2CC41B8900B232A5 /* AppInfo.swift in Sources */,
A52FFF5B2CAA54B1000C6A5B /* FullscreenMode+Extension.swift in Sources */,
A5333E222B5A2128008AEFF7 /* SurfaceView_AppKit.swift in Sources */,
A5CA378E2D31D6C300931030 /* Weak.swift in Sources */,
A5CDF1952AAFA19600513312 /* ConfigurationErrorsView.swift in Sources */,
A55B7BBC29B6FC330055DE60 /* SurfaceView.swift in Sources */,
A5333E1C2B5A1CE3008AEFF7 /* CrossKit.swift in Sources */,
+ A5B4EA852DFE691B0022C3A2 /* NSMenuItem+Extension.swift in Sources */,
A5874D992DAD751B00E83852 /* CGS.swift in Sources */,
+ A586366B2DF0A98C00E04A10 /* Array+Extension.swift in Sources */,
+ A5E408472E04852B0035FEAC /* InputIntent.swift in Sources */,
+ A51544FE2DFB111C009E85D8 /* TitlebarTabsTahoeTerminalWindow.swift in Sources */,
A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */,
A56D58862ACDDB4100508D2C /* Ghostty.Shell.swift in Sources */,
A5985CD72C320C4500C57AD3 /* String+Extension.swift in Sources */,
A5A2A3CC2D444ABB0033CF96 /* NSApplication+Extension.swift in Sources */,
- A59630A22AF0415000D64628 /* Ghostty.TerminalSplit.swift in Sources */,
+ A5E408302E0271320035FEAC /* GhosttyIntentError.swift in Sources */,
+ A5E4083A2E0449BD0035FEAC /* Ghostty.Command.swift in Sources */,
+ A5E408452E0483FD0035FEAC /* KeybindIntent.swift in Sources */,
A5FEB3002ABB69450068369E /* main.swift in Sources */,
A53A297F2DB4480F00B6E02C /* EventModifiers+Extension.swift in Sources */,
+ A5E4082E2E0237460035FEAC /* NewTerminalIntent.swift in Sources */,
A53A297B2DB2E49700B6E02C /* CommandPalette.swift in Sources */,
A55B7BB829B6F53A0055DE60 /* Package.swift in Sources */,
- A51B78472AF4B58B00F3EDB9 /* TerminalWindow.swift in Sources */,
+ A51B78472AF4B58B00F3EDB9 /* TitlebarTabsVenturaTerminalWindow.swift in Sources */,
A57D79272C9C879B001D522E /* SecureInput.swift in Sources */,
A5CEAFDC29B8009000646FDA /* SplitView.swift in Sources */,
+ A5593FE12DF8D74000B47B10 /* HiddenTitlebarTerminalWindow.swift in Sources */,
+ A5E4083C2E044DB50035FEAC /* Ghostty.Error.swift in Sources */,
A5CDF1932AAF9E0800513312 /* ConfigurationErrorsController.swift in Sources */,
A53A6C032CCC1B7F00943E98 /* Ghostty.Action.swift in Sources */,
A54B0CED2D0CFB7700CBEFF8 /* ColorizedGhosttyIcon.swift in Sources */,
A5CA378C2D2A4DEB00931030 /* KeyboardLayout.swift in Sources */,
A54B0CEF2D0D2E2800CBEFF8 /* ColorizedGhosttyIconImage.swift in Sources */,
A59FB5D12AE0DEA7009128F3 /* MetalView.swift in Sources */,
- A55685E029A03A9F004303CE /* AppError.swift in Sources */,
A599CDB02CF103F60049FA26 /* NSAppearance+Extension.swift in Sources */,
A52FFF572CA90484000C6A5B /* QuickTerminalScreen.swift in Sources */,
A5CC36132C9CD72D004D6760 /* SecureInputOverlay.swift in Sources */,
+ A5E408402E04532C0035FEAC /* CommandEntity.swift in Sources */,
+ A5E4082A2E022E9E0035FEAC /* TabGroupCloseCoordinator.swift in Sources */,
A535B9DA299C569B0017E2E4 /* ErrorView.swift in Sources */,
A53A29882DB69D2F00B6E02C /* TerminalCommandPalette.swift in Sources */,
A51BFC202B2FB64F00E92F16 /* AboutController.swift in Sources */,
A5CEAFFF29C2410700646FDA /* Backport.swift in Sources */,
A5E112952AF73E8A00C6E0C2 /* ClipboardConfirmationController.swift in Sources */,
A596309E2AEE1D6C00D64628 /* TerminalView.swift in Sources */,
+ A58636662DEF964100E04A10 /* TerminalSplitTreeView.swift in Sources */,
A52FFF592CAA4FF3000C6A5B /* Fullscreen.swift in Sources */,
- AEF9CE242B6AD07A0017E195 /* TerminalToolbar.swift in Sources */,
C159E81D2B66A06B00FDFE9C /* OSColor+Extension.swift in Sources */,
A5CEAFDE29B8058B00646FDA /* SplitView.Divider.swift in Sources */,
A5E112972AF7401B00C6E0C2 /* ClipboardConfirmationView.swift in Sources */,
@@ -748,6 +900,7 @@
buildActionMask = 2147483647;
files = (
A5CBD0592C9F37B10017A1AE /* Backport.swift in Sources */,
+ A553F4062E05E93000257779 /* Optional+Extension.swift in Sources */,
A53D0C942B53B43700305CE6 /* iOSApp.swift in Sources */,
A514C8D72B54A16400493A16 /* Ghostty.Config.swift in Sources */,
A5333E232B5A219A008AEFF7 /* SurfaceView.swift in Sources */,
@@ -757,6 +910,7 @@
A53D0C9B2B543F3B00305CE6 /* Ghostty.App.swift in Sources */,
A5333E242B5A22D9008AEFF7 /* Ghostty.Shell.swift in Sources */,
A5985CD82C320C4500C57AD3 /* String+Extension.swift in Sources */,
+ A553F4072E05E93D00257779 /* Array+Extension.swift in Sources */,
C159E89D2B69A2EF00FDFE9C /* OSColor+Extension.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -822,7 +976,7 @@
3B39CAA32B33946300DABEB8 /* ReleaseLocal */ = {
isa = XCBuildConfiguration;
buildSettings = {
- ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_APPICON_NAME = Ghostty;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
CLANG_ENABLE_MODULES = YES;
@@ -992,7 +1146,7 @@
A5B30541299BEAAB0047F10C /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
- ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_APPICON_NAME = Ghostty;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
CLANG_ENABLE_MODULES = YES;
@@ -1046,7 +1200,7 @@
A5B30542299BEAAB0047F10C /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
- ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_APPICON_NAME = Ghostty;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
CLANG_ENABLE_MODULES = YES;
@@ -1099,7 +1253,7 @@
A5D449A82B53AE7B000F5B83 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
- ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_APPICON_NAME = Ghostty;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
@@ -1138,7 +1292,7 @@
A5D449A92B53AE7B000F5B83 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
- ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_APPICON_NAME = Ghostty;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
@@ -1177,7 +1331,7 @@
A5D449AA2B53AE7B000F5B83 /* ReleaseLocal */ = {
isa = XCBuildConfiguration;
buildSettings = {
- ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_APPICON_NAME = Ghostty;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift
index 38b26f606..734fcbc20 100644
--- a/macos/Sources/App/macOS/AppDelegate.swift
+++ b/macos/Sources/App/macOS/AppDelegate.swift
@@ -18,6 +18,7 @@ class AppDelegate: NSObject,
)
/// Various menu items so that we can programmatically sync the keyboard shortcut with the Ghostty config
+ @IBOutlet private var menuAbout: NSMenuItem?
@IBOutlet private var menuServices: NSMenu?
@IBOutlet private var menuCheckForUpdates: NSMenuItem?
@IBOutlet private var menuOpenConfig: NSMenuItem?
@@ -36,6 +37,8 @@ class AppDelegate: NSObject,
@IBOutlet private var menuCloseWindow: NSMenuItem?
@IBOutlet private var menuCloseAllWindows: NSMenuItem?
+ @IBOutlet private var menuUndo: NSMenuItem?
+ @IBOutlet private var menuRedo: NSMenuItem?
@IBOutlet private var menuCopy: NSMenuItem?
@IBOutlet private var menuPaste: NSMenuItem?
@IBOutlet private var menuPasteSelection: NSMenuItem?
@@ -85,11 +88,14 @@ class AppDelegate: NSObject,
/// The ghostty global state. Only one per process.
let ghostty: Ghostty.App = Ghostty.App()
- /// Manages our terminal windows.
- let terminalManager: TerminalManager
+ /// The global undo manager for app-level state such as window restoration.
+ lazy var undoManager = ExpiringUndoManager()
/// Our quick terminal. This starts out uninitialized and only initializes if used.
- private var quickController: QuickTerminalController? = nil
+ private(set) lazy var quickController = QuickTerminalController(
+ ghostty,
+ position: derivedConfig.quickTerminalPosition
+ )
/// Manages updates
let updaterController: SPUStandardUpdaterController
@@ -114,7 +120,6 @@ class AppDelegate: NSObject,
}
override init() {
- terminalManager = TerminalManager(ghostty)
updaterController = SPUStandardUpdaterController(
// Important: we must not start the updater here because we need to read our configuration
// first to determine whether we're automatically checking, downloading, etc. The updater
@@ -197,6 +202,16 @@ class AppDelegate: NSObject,
name: .ghosttyBellDidRing,
object: nil
)
+ NotificationCenter.default.addObserver(
+ self,
+ selector: #selector(ghosttyNewWindow(_:)),
+ name: Ghostty.Notification.ghosttyNewWindow,
+ object: nil)
+ NotificationCenter.default.addObserver(
+ self,
+ selector: #selector(ghosttyNewTab(_:)),
+ name: Ghostty.Notification.ghosttyNewTab,
+ object: nil)
// Configure user notifications
let actions = [
@@ -231,6 +246,9 @@ class AppDelegate: NSObject,
ghostty_app_set_color_scheme(app, scheme)
}
+
+ // Setup our menu
+ setupMenuImages()
}
func applicationDidBecomeActive(_ notification: Notification) {
@@ -248,8 +266,10 @@ class AppDelegate: NSObject,
// is possible to have other windows in a few scenarios:
// - if we're opening a URL since `application(_:openFile:)` is called before this.
// - if we're restoring from persisted state
- if terminalManager.windows.count == 0 && derivedConfig.initialWindow {
- terminalManager.newWindow()
+ if TerminalController.all.isEmpty && derivedConfig.initialWindow {
+ undoManager.disableUndoRegistration()
+ _ = TerminalController.newWindow(ghostty)
+ undoManager.enableUndoRegistration()
}
}
}
@@ -269,7 +289,7 @@ class AppDelegate: NSObject,
// NOTE(mitchellh): I don't think we need this check at all anymore. I'm keeping it
// here because I don't want to remove it in a patch release cycle but we should
// target removing it soon.
- if (self.quickController == nil && windows.allSatisfy { !$0.isVisible }) {
+ if (windows.allSatisfy { !$0.isVisible }) {
return .terminateNow
}
@@ -316,6 +336,13 @@ class AppDelegate: NSObject,
}
}
+ func applicationWillTerminate(_ notification: Notification) {
+ // We have no notifications we want to persist after death,
+ // so remove them all now. In the future we may want to be
+ // more selective and only remove surface-targeted notifications.
+ UNUserNotificationCenter.current().removeAllDeliveredNotifications()
+ }
+
/// This is called when the application is already open and someone double-clicks the icon
/// or clicks the dock icon.
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
@@ -327,10 +354,15 @@ class AppDelegate: NSObject,
// This is possible with flag set to false if there a race where the
// window is still initializing and is not visible but the user clicked
// the dock icon.
- guard terminalManager.windows.count == 0 else { return true }
+ guard TerminalController.all.isEmpty else { return true }
+
+ // If the application isn't active yet then we don't want to process
+ // this because we're not ready. This happens sometimes in Xcode runs
+ // but I haven't seen it happen in releases. I'm unsure why.
+ guard applicationHasBecomeActive else { return true }
// No visible windows, open a new one.
- terminalManager.newWindow()
+ _ = TerminalController.newWindow(ghostty)
return false
}
@@ -346,16 +378,24 @@ class AppDelegate: NSObject,
var config = Ghostty.SurfaceConfiguration()
if (isDirectory.boolValue) {
- // When opening a directory, create a new tab in the main window with that as the working directory.
+ // When opening a directory, create a new tab in the main
+ // window with that as the working directory.
// If no windows exist, a new one will be created.
config.workingDirectory = filename
- terminalManager.newTab(withBaseConfig: config)
+ _ = TerminalController.newTab(ghostty, withBaseConfig: config)
} else {
- // When opening a file, open a new window with that file as the command,
- // and its parent directory as the working directory.
- config.command = filename
+ // When opening a file, we want to execute the file. To do this, we
+ // don't override the command directly, because it won't load the
+ // profile/rc files for the shell, which is super important on macOS
+ // due to things like Homebrew. Instead, we set the command to
+ // `<filename>; exit` which is what Terminal and iTerm2 do.
+ config.initialInput = "\(filename); exit\n"
+
+ // Set the parent directory to our working directory so that relative
+ // paths in scripts work.
config.workingDirectory = (filename as NSString).deletingLastPathComponent
- terminalManager.newWindow(withBaseConfig: config)
+
+ _ = TerminalController.newWindow(ghostty, withBaseConfig: config)
}
return true
@@ -366,6 +406,46 @@ class AppDelegate: NSObject,
return dockMenu
}
+ /// Setup all the images for our menu items.
+ private func setupMenuImages() {
+ // Note: This COULD Be done all in the xib file, but I find it easier to
+ // modify this stuff as code.
+ self.menuAbout?.setImageIfDesired(systemSymbolName: "info.circle")
+ self.menuCheckForUpdates?.setImageIfDesired(systemSymbolName: "square.and.arrow.down")
+ self.menuOpenConfig?.setImageIfDesired(systemSymbolName: "gear")
+ self.menuReloadConfig?.setImageIfDesired(systemSymbolName: "arrow.trianglehead.2.clockwise.rotate.90")
+ self.menuSecureInput?.setImageIfDesired(systemSymbolName: "lock.display")
+ self.menuNewWindow?.setImageIfDesired(systemSymbolName: "macwindow.badge.plus")
+ self.menuNewTab?.setImageIfDesired(systemSymbolName: "macwindow")
+ self.menuSplitRight?.setImageIfDesired(systemSymbolName: "rectangle.righthalf.inset.filled")
+ self.menuSplitLeft?.setImageIfDesired(systemSymbolName: "rectangle.leadinghalf.inset.filled")
+ self.menuSplitUp?.setImageIfDesired(systemSymbolName: "rectangle.tophalf.inset.filled")
+ self.menuSplitDown?.setImageIfDesired(systemSymbolName: "rectangle.bottomhalf.inset.filled")
+ self.menuClose?.setImageIfDesired(systemSymbolName: "xmark")
+ self.menuIncreaseFontSize?.setImageIfDesired(systemSymbolName: "textformat.size.larger")
+ self.menuResetFontSize?.setImageIfDesired(systemSymbolName: "textformat.size")
+ self.menuDecreaseFontSize?.setImageIfDesired(systemSymbolName: "textformat.size.smaller")
+ self.menuCommandPalette?.setImageIfDesired(systemSymbolName: "filemenu.and.selection")
+ self.menuQuickTerminal?.setImageIfDesired(systemSymbolName: "apple.terminal")
+ self.menuChangeTitle?.setImageIfDesired(systemSymbolName: "pencil.line")
+ self.menuTerminalInspector?.setImageIfDesired(systemSymbolName: "scope")
+ self.menuToggleFullScreen?.setImageIfDesired(systemSymbolName: "square.arrowtriangle.4.outward")
+ self.menuToggleVisibility?.setImageIfDesired(systemSymbolName: "eye")
+ self.menuZoomSplit?.setImageIfDesired(systemSymbolName: "arrow.up.left.and.arrow.down.right")
+ self.menuPreviousSplit?.setImageIfDesired(systemSymbolName: "chevron.backward.2")
+ self.menuNextSplit?.setImageIfDesired(systemSymbolName: "chevron.forward.2")
+ self.menuEqualizeSplits?.setImageIfDesired(systemSymbolName: "inset.filled.topleft.topright.bottomleft.bottomright.rectangle")
+ self.menuSelectSplitLeft?.setImageIfDesired(systemSymbolName: "arrow.left")
+ self.menuSelectSplitRight?.setImageIfDesired(systemSymbolName: "arrow.right")
+ self.menuSelectSplitAbove?.setImageIfDesired(systemSymbolName: "arrow.up")
+ self.menuSelectSplitBelow?.setImageIfDesired(systemSymbolName: "arrow.down")
+ self.menuMoveSplitDividerUp?.setImageIfDesired(systemSymbolName: "arrow.up.to.line")
+ self.menuMoveSplitDividerDown?.setImageIfDesired(systemSymbolName: "arrow.down.to.line")
+ self.menuMoveSplitDividerLeft?.setImageIfDesired(systemSymbolName: "arrow.left.to.line")
+ self.menuMoveSplitDividerRight?.setImageIfDesired(systemSymbolName: "arrow.right.to.line")
+ self.menuFloatOnTop?.setImageIfDesired(systemSymbolName: "square.3.layers.3d.top.filled")
+ }
+
/// Sync all of our menu item keyboard shortcuts with the Ghostty configuration.
private func syncMenuShortcuts(_ config: Ghostty.Config) {
guard ghostty.readiness == .ready else { return }
@@ -386,6 +466,8 @@ class AppDelegate: NSObject,
syncMenuShortcut(config, action: "new_split:down", menuItem: self.menuSplitDown)
syncMenuShortcut(config, action: "new_split:up", menuItem: self.menuSplitUp)
+ syncMenuShortcut(config, action: "undo", menuItem: self.menuUndo)
+ syncMenuShortcut(config, action: "redo", menuItem: self.menuRedo)
syncMenuShortcut(config, action: "copy_to_clipboard", menuItem: self.menuCopy)
syncMenuShortcut(config, action: "paste_from_clipboard", menuItem: self.menuPaste)
syncMenuShortcut(config, action: "paste_from_selection", menuItem: self.menuPasteSelection)
@@ -442,10 +524,6 @@ class AppDelegate: NSObject,
menu.keyEquivalentModifierMask = .init(swiftUIFlags: shortcut.modifiers)
}
- private func focusedSurface() -> ghostty_surface_t? {
- return terminalManager.focusedSurface?.surface
- }
-
// MARK: Notifications and Events
/// This handles events from the NSEvent.addLocalEventMonitor. We use this so we can get
@@ -530,11 +608,13 @@ class AppDelegate: NSObject,
}
@objc private func ghosttyBellDidRing(_ notification: Notification) {
- // Bounce the dock icon if we're not focused.
- NSApp.requestUserAttention(.informationalRequest)
+ if (ghostty.config.bellFeatures.contains(.attention)) {
+ // Bounce the dock icon if we're not focused.
+ NSApp.requestUserAttention(.informationalRequest)
- // Handle setting the dock badge based on permissions
- ghosttyUpdateBadgeForBell()
+ // Handle setting the dock badge based on permissions
+ ghosttyUpdateBadgeForBell()
+ }
}
private func ghosttyUpdateBadgeForBell() {
@@ -576,6 +656,26 @@ class AppDelegate: NSObject,
}
}
+ @objc private func ghosttyNewWindow(_ notification: Notification) {
+ let configAny = notification.userInfo?[Ghostty.Notification.NewSurfaceConfigKey]
+ let config = configAny as? Ghostty.SurfaceConfiguration
+ _ = TerminalController.newWindow(ghostty, withBaseConfig: config)
+ }
+
+ @objc private func ghosttyNewTab(_ notification: Notification) {
+ guard let surfaceView = notification.object as? Ghostty.SurfaceView else { return }
+ guard let window = surfaceView.window else { return }
+
+ // We only want to listen to new tabs if the focused parent is
+ // a regular terminal controller.
+ guard window.windowController is TerminalController else { return }
+
+ let configAny = notification.userInfo?[Ghostty.Notification.NewSurfaceConfigKey]
+ let config = configAny as? Ghostty.SurfaceConfiguration
+
+ _ = TerminalController.newTab(ghostty, from: window, withBaseConfig: config)
+ }
+
private func setDockBadge(_ label: String? = "•") {
NSApp.dockTile.badgeLabel = label
NSApp.dockTile.display()
@@ -611,7 +711,7 @@ class AppDelegate: NSObject,
// Config could change keybindings, so update everything that depends on that
syncMenuShortcuts(config)
- terminalManager.relabelAllTabs()
+ TerminalController.all.forEach { $0.relabelTabs() }
// Config could change window appearance. We wrap this in an async queue because when
// this is called as part of application launch it can deadlock with an internal
@@ -740,9 +840,11 @@ class AppDelegate: NSObject,
//MARK: - GhosttyAppDelegate
func findSurface(forUUID uuid: UUID) -> Ghostty.SurfaceView? {
- for c in terminalManager.windows {
- if let v = c.controller.surfaceTree?.findUUID(uuid: uuid) {
- return v
+ for c in TerminalController.all {
+ for view in c.surfaceTree {
+ if view.uuid == uuid {
+ return view
+ }
}
}
@@ -793,7 +895,7 @@ class AppDelegate: NSObject,
}
@IBAction func newWindow(_ sender: Any?) {
- terminalManager.newWindow()
+ _ = TerminalController.newWindow(ghostty)
// We also activate our app so that it becomes front. This may be
// necessary for the dock menu.
@@ -801,7 +903,7 @@ class AppDelegate: NSObject,
}
@IBAction func newTab(_ sender: Any?) {
- terminalManager.newTab()
+ _ = TerminalController.newTab(ghostty)
// We also activate our app so that it becomes front. This may be
// necessary for the dock menu.
@@ -809,7 +911,7 @@ class AppDelegate: NSObject,
}
@IBAction func closeAllWindows(_ sender: Any?) {
- terminalManager.closeAllWindows()
+ TerminalController.closeAllWindows()
AboutController.shared.hide()
}
@@ -827,14 +929,6 @@ class AppDelegate: NSObject,
}
@IBAction func toggleQuickTerminal(_ sender: Any) {
- if quickController == nil {
- quickController = QuickTerminalController(
- ghostty,
- position: derivedConfig.quickTerminalPosition
- )
- }
-
- guard let quickController = self.quickController else { return }
quickController.toggle()
}
@@ -871,6 +965,14 @@ class AppDelegate: NSObject,
NSApplication.shared.arrangeInFront(sender)
}
+ @IBAction func undo(_ sender: Any?) {
+ undoManager.undo()
+ }
+
+ @IBAction func redo(_ sender: Any?) {
+ undoManager.redo()
+ }
+
private struct DerivedConfig {
let initialWindow: Bool
let shouldQuitAfterLastWindowClosed: Bool
@@ -960,6 +1062,22 @@ extension AppDelegate: NSMenuItemValidation {
// terminal window (not quick terminal).
return NSApp.keyWindow is TerminalWindow
+ case #selector(undo(_:)):
+ if undoManager.canUndo {
+ item.title = "Undo \(undoManager.undoActionName)"
+ } else {
+ item.title = "Undo"
+ }
+ return undoManager.canUndo
+
+ case #selector(redo(_:)):
+ if undoManager.canRedo {
+ item.title = "Redo \(undoManager.redoActionName)"
+ } else {
+ item.title = "Redo"
+ }
+ return undoManager.canRedo
+
default:
return true
}
diff --git a/macos/Sources/App/macOS/MainMenu.xib b/macos/Sources/App/macOS/MainMenu.xib
index 828e82bd0..5cd6d9bec 100644
--- a/macos/Sources/App/macOS/MainMenu.xib
+++ b/macos/Sources/App/macOS/MainMenu.xib
@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
-<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="23727" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
+<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="24093.7" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
<dependencies>
<deployment identifier="macosx"/>
- <plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="23727"/>
+ <plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="24093.7"/>
</dependencies>
<objects>
<customObject id="-2" userLabel="File's Owner" customClass="NSApplication">
@@ -14,6 +14,7 @@
<customObject id="-3" userLabel="Application" customClass="NSObject"/>
<customObject id="bbz-4X-AYv" userLabel="AppDelegate" customClass="AppDelegate" customModule="Ghostty" customModuleProvider="target">
<connections>
+ <outlet property="menuAbout" destination="5kV-Vb-QxS" id="Y5y-UO-NK6"/>
<outlet property="menuBringAllToFront" destination="LE2-aR-0XJ" id="AP9-oK-60V"/>
<outlet property="menuChangeTitle" destination="24I-xg-qIq" id="kg6-kT-jNL"/>
<outlet property="menuCheckForUpdates" destination="GEA-5y-yzH" id="0nV-Tf-nJQ"/>
@@ -40,6 +41,7 @@
<outlet property="menuPreviousSplit" destination="Lic-px-1wg" id="Rto-CG-yRe"/>
<outlet property="menuQuickTerminal" destination="1pv-LF-NBJ" id="glN-5B-IGi"/>
<outlet property="menuQuit" destination="4sb-4s-VLi" id="qYN-S1-6UW"/>
+ <outlet property="menuRedo" destination="EX8-lB-4s7" id="wON-2J-yT1"/>
<outlet property="menuReloadConfig" destination="KKH-XX-5py" id="Wvp-7J-wqX"/>
<outlet property="menuResetFontSize" destination="Jah-MY-aLX" id="ger-qM-wrm"/>
<outlet property="menuReturnToDefaultSize" destination="Gbx-Vi-OGC" id="po9-qC-Iz6"/>
@@ -57,6 +59,7 @@
<outlet property="menuTerminalInspector" destination="QwP-M5-fvh" id="wJi-Dh-S9f"/>
<outlet property="menuToggleFullScreen" destination="8kY-Pi-KaY" id="yQg-6V-OO6"/>
<outlet property="menuToggleVisibility" destination="DOX-wA-ilh" id="iBj-Bc-2bq"/>
+ <outlet property="menuUndo" destination="r83-CV-syt" id="bU9-0b-xgQ"/>
<outlet property="menuUseAsDefault" destination="TrB-O8-g8H" id="af4-Jh-2HU"/>
<outlet property="menuZoomSplit" destination="oPd-mn-IEH" id="wTu-jK-egI"/>
</connections>
@@ -204,6 +207,19 @@
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Edit" id="iU4-OB-ccf">
<items>
+ <menuItem title="Undo" id="r83-CV-syt">
+ <modifierMask key="keyEquivalentModifierMask"/>
+ <connections>
+ <action selector="undo:" target="-1" id="jrW-j3-OZj"/>
+ </connections>
+ </menuItem>
+ <menuItem title="Redo" id="EX8-lB-4s7">
+ <modifierMask key="keyEquivalentModifierMask"/>
+ <connections>
+ <action selector="redo:" target="-1" id="7UK-Hj-s4O"/>
+ </connections>
+ </menuItem>
+ <menuItem isSeparatorItem="YES" id="4O9-zO-zB9"/>
<menuItem title="Copy" id="Jqf-pv-Zcu">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
@@ -236,16 +252,16 @@
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="View" id="m6z-2H-VW7">
<items>
- <menuItem title="Increase Font Size" id="CIH-ey-Z6x" userLabel="Increase Font Size">
+ <menuItem title="Reset Font Size" id="Jah-MY-aLX">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
- <action selector="increaseFontSize:" target="-1" id="361-5E-7PY"/>
+ <action selector="resetFontSize:" target="-1" id="3dh-T9-IkH"/>
</connections>
</menuItem>
- <menuItem title="Reset Font Size" id="Jah-MY-aLX">
+ <menuItem title="Increase Font Size" id="CIH-ey-Z6x" userLabel="Increase Font Size">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
- <action selector="resetFontSize:" target="-1" id="3dh-T9-IkH"/>
+ <action selector="increaseFontSize:" target="-1" id="361-5E-7PY"/>
</connections>
</menuItem>
<menuItem title="Decrease Font Size" id="kzb-SZ-dOA">
diff --git a/macos/Sources/Features/App Intents/CloseTerminalIntent.swift b/macos/Sources/Features/App Intents/CloseTerminalIntent.swift
new file mode 100644
index 000000000..923d22c97
--- /dev/null
+++ b/macos/Sources/Features/App Intents/CloseTerminalIntent.swift
@@ -0,0 +1,35 @@
+import AppKit
+import AppIntents
+import GhosttyKit
+
+struct CloseTerminalIntent: AppIntent {
+ static var title: LocalizedStringResource = "Close Terminal"
+ static var description = IntentDescription("Close an existing terminal.")
+
+ @Parameter(
+ title: "Terminal",
+ description: "The terminal to close.",
+ )
+ var terminal: TerminalEntity
+
+ @available(macOS 26.0, *)
+ static var supportedModes: IntentModes = .background
+
+ @MainActor
+ func perform() async throws -> some IntentResult {
+ guard await requestIntentPermission() else {
+ throw GhosttyIntentError.permissionDenied
+ }
+
+ guard let surfaceView = terminal.surfaceView else {
+ throw GhosttyIntentError.surfaceNotFound
+ }
+
+ guard let controller = surfaceView.window?.windowController as? BaseTerminalController else {
+ return .result()
+ }
+
+ controller.closeSurface(surfaceView, withConfirmation: false)
+ return .result()
+ }
+}
diff --git a/macos/Sources/Features/App Intents/CommandPaletteIntent.swift b/macos/Sources/Features/App Intents/CommandPaletteIntent.swift
new file mode 100644
index 000000000..fa983054b
--- /dev/null
+++ b/macos/Sources/Features/App Intents/CommandPaletteIntent.swift
@@ -0,0 +1,38 @@
+import AppKit
+import AppIntents
+
+/// App intent that invokes a command palette entry.
+@available(macOS 14.0, *)
+struct CommandPaletteIntent: AppIntent {
+ static var title: LocalizedStringResource = "Invoke Command Palette Action"
+
+ @Parameter(
+ title: "Terminal",
+ description: "The terminal to base available commands from."
+ )
+ var terminal: TerminalEntity
+
+ @Parameter(
+ title: "Command",
+ description: "The command to invoke.",
+ optionsProvider: CommandQuery()
+ )
+ var command: CommandEntity
+
+ @available(macOS 26.0, *)
+ static var supportedModes: IntentModes = .background
+
+ @MainActor
+ func perform() async throws -> some IntentResult & ReturnsValue<Bool> {
+ guard await requestIntentPermission() else {
+ throw GhosttyIntentError.permissionDenied
+ }
+
+ guard let surface = terminal.surfaceModel else {
+ throw GhosttyIntentError.surfaceNotFound
+ }
+
+ let performed = surface.perform(action: command.action)
+ return .result(value: performed)
+ }
+}
diff --git a/macos/Sources/Features/App Intents/Entities/CommandEntity.swift b/macos/Sources/Features/App Intents/Entities/CommandEntity.swift
new file mode 100644
index 000000000..f7abcc6de
--- /dev/null
+++ b/macos/Sources/Features/App Intents/Entities/CommandEntity.swift
@@ -0,0 +1,128 @@
+import AppIntents
+
+// MARK: AppEntity
+
+@available(macOS 14.0, *)
+struct CommandEntity: AppEntity {
+ let id: ID
+
+ // Note: for macOS 26 we can move all the properties to @ComputedProperty.
+
+ @Property(title: "Title")
+ var title: String
+
+ @Property(title: "Description")
+ var description: String
+
+ @Property(title: "Action")
+ var action: String
+
+ /// The underlying data model
+ let command: Ghostty.Command
+
+ /// A command identifier is a composite key based on the terminal and action.
+ struct ID: Hashable {
+ let terminalId: TerminalEntity.ID
+ let actionKey: String
+
+ init(terminalId: TerminalEntity.ID, actionKey: String) {
+ self.terminalId = terminalId
+ self.actionKey = actionKey
+ }
+ }
+
+ static var typeDisplayRepresentation: TypeDisplayRepresentation {
+ TypeDisplayRepresentation(name: "Command Palette Command")
+ }
+
+ var displayRepresentation: DisplayRepresentation {
+ DisplayRepresentation(
+ title: LocalizedStringResource(stringLiteral: command.title),
+ subtitle: LocalizedStringResource(stringLiteral: command.description),
+ )
+ }
+
+ static var defaultQuery = CommandQuery()
+
+ init(_ command: Ghostty.Command, for terminal: TerminalEntity) {
+ self.id = .init(terminalId: terminal.id, actionKey: command.actionKey)
+ self.command = command
+ self.title = command.title
+ self.description = command.description
+ self.action = command.action
+ }
+}
+
+@available(macOS 14.0, *)
+extension CommandEntity.ID: RawRepresentable {
+ var rawValue: String {
+ return "\(terminalId):\(actionKey)"
+ }
+
+ init?(rawValue: String) {
+ let components = rawValue.split(separator: ":", maxSplits: 1)
+ guard components.count == 2 else { return nil }
+
+ guard let terminalId = TerminalEntity.ID(uuidString: String(components[0])) else {
+ return nil
+ }
+
+ self.terminalId = terminalId
+ self.actionKey = String(components[1])
+ }
+}
+
+// Required by AppEntity
+@available(macOS 14.0, *)
+extension CommandEntity.ID: EntityIdentifierConvertible {
+ static func entityIdentifier(for entityIdentifierString: String) -> CommandEntity.ID? {
+ .init(rawValue: entityIdentifierString)
+ }
+
+ var entityIdentifierString: String {
+ rawValue
+ }
+}
+
+// MARK: EntityQuery
+
+@available(macOS 14.0, *)
+struct CommandQuery: EntityQuery {
+ // Inject our terminal parameter from our command palette intent.
+ @IntentParameterDependency<CommandPaletteIntent>(\.$terminal)
+ var commandPaletteIntent
+
+ @MainActor
+ func entities(for identifiers: [CommandEntity.ID]) async throws -> [CommandEntity] {
+ // Extract unique terminal IDs to avoid fetching duplicates
+ let terminalIds = Set(identifiers.map(\.terminalId))
+ let terminals = try await TerminalEntity.defaultQuery.entities(for: Array(terminalIds))
+
+ // Build a cache of terminals and their available commands
+ // This avoids repeated command fetching for the same terminal
+ typealias Tuple = (terminal: TerminalEntity, commands: [Ghostty.Command])
+ let commandMap: [TerminalEntity.ID: Tuple] =
+ terminals.reduce(into: [:]) { result, terminal in
+ guard let commands = try? terminal.surfaceModel?.commands() else { return }
+ result[terminal.id] = (terminal: terminal, commands: commands)
+ }
+
+ // Map each identifier to its corresponding CommandEntity. If a command doesn't
+ // exist it maps to nil and is removed via compactMap.
+ return identifiers.compactMap { id in
+ guard let (terminal, commands) = commandMap[id.terminalId],
+ let command = commands.first(where: { $0.actionKey == id.actionKey }) else {
+ return nil
+ }
+
+ return CommandEntity(command, for: terminal)
+ }
+ }
+
+ @MainActor
+ func suggestedEntities() async throws -> [CommandEntity] {
+ guard let terminal = commandPaletteIntent?.terminal,
+ let surface = terminal.surfaceModel else { return [] }
+ return try surface.commands().map { CommandEntity($0, for: terminal) }
+ }
+}
diff --git a/macos/Sources/Features/App Intents/Entities/TerminalEntity.swift b/macos/Sources/Features/App Intents/Entities/TerminalEntity.swift
new file mode 100644
index 000000000..e29fbba3f
--- /dev/null
+++ b/macos/Sources/Features/App Intents/Entities/TerminalEntity.swift
@@ -0,0 +1,139 @@
+import AppKit
+import AppIntents
+import SwiftUI
+
+struct TerminalEntity: AppEntity {
+ let id: UUID
+
+ @Property(title: "Title")
+ var title: String
+
+ @Property(title: "Working Directory")
+ var workingDirectory: String?
+
+ @Property(title: "Kind")
+ var kind: Kind
+
+ @MainActor
+ @DeferredProperty(title: "Full Contents")
+ @available(macOS 26.0, *)
+ var screenContents: String? {
+ get async {
+ guard let surfaceView else { return nil }
+ return surfaceView.cachedScreenContents.get()
+ }
+ }
+
+ @MainActor
+ @DeferredProperty(title: "Visible Contents")
+ @available(macOS 26.0, *)
+ var visibleContents: String? {
+ get async {
+ guard let surfaceView else { return nil }
+ return surfaceView.cachedVisibleContents.get()
+ }
+ }
+
+ var screenshot: Image?
+
+ static var typeDisplayRepresentation: TypeDisplayRepresentation {
+ TypeDisplayRepresentation(name: "Terminal")
+ }
+
+ @MainActor
+ var displayRepresentation: DisplayRepresentation {
+ var rep = DisplayRepresentation(title: "\(title)")
+ if let screenshot,
+ let nsImage = ImageRenderer(content: screenshot).nsImage,
+ let data = nsImage.tiffRepresentation {
+ rep.image = .init(data: data)
+ }
+
+ return rep
+ }
+
+ /// Returns the view associated with this entity. This may no longer exist.
+ @MainActor
+ var surfaceView: Ghostty.SurfaceView? {
+ Self.defaultQuery.all.first { $0.uuid == self.id }
+ }
+
+ @MainActor
+ var surfaceModel: Ghostty.Surface? {
+ surfaceView?.surfaceModel
+ }
+
+ static var defaultQuery = TerminalQuery()
+
+ init(_ view: Ghostty.SurfaceView) {
+ self.id = view.uuid
+ self.title = view.title
+ self.workingDirectory = view.pwd
+ self.screenshot = view.screenshot()
+
+ // Determine the kind based on the window controller type
+ if view.window?.windowController is QuickTerminalController {
+ self.kind = .quick
+ } else {
+ self.kind = .normal
+ }
+ }
+}
+
+extension TerminalEntity {
+ enum Kind: String, AppEnum {
+ case normal
+ case quick
+
+ static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Terminal Kind")
+
+ static var caseDisplayRepresentations: [Self: DisplayRepresentation] = [
+ .normal: .init(title: "Normal"),
+ .quick: .init(title: "Quick")
+ ]
+ }
+}
+
+struct TerminalQuery: EntityStringQuery, EnumerableEntityQuery {
+ @MainActor
+ func entities(for identifiers: [TerminalEntity.ID]) async throws -> [TerminalEntity] {
+ return all.filter {
+ identifiers.contains($0.uuid)
+ }.map {
+ TerminalEntity($0)
+ }
+ }
+
+ @MainActor
+ func entities(matching string: String) async throws -> [TerminalEntity] {
+ return all.filter {
+ $0.title.localizedCaseInsensitiveContains(string)
+ }.map {
+ TerminalEntity($0)
+ }
+ }
+
+ @MainActor
+ func allEntities() async throws -> [TerminalEntity] {
+ return all.map { TerminalEntity($0) }
+ }
+
+ @MainActor
+ func suggestedEntities() async throws -> [TerminalEntity] {
+ return try await allEntities()
+ }
+
+ @MainActor
+ var all: [Ghostty.SurfaceView] {
+ // Find all of our terminal windows. This will include the quick terminal
+ // but only if it was previously opened.
+ let controllers = NSApp.windows.compactMap {
+ $0.windowController as? BaseTerminalController
+ }
+
+ // Get all our surfaces
+ return controllers.flatMap {
+ $0.surfaceTree.root?.leaves() ?? []
+ }
+ }
+}
diff --git a/macos/Sources/Features/App Intents/GetTerminalDetailsIntent.swift b/macos/Sources/Features/App Intents/GetTerminalDetailsIntent.swift
new file mode 100644
index 000000000..1cbaa9d68
--- /dev/null
+++ b/macos/Sources/Features/App Intents/GetTerminalDetailsIntent.swift
@@ -0,0 +1,69 @@
+import AppKit
+import AppIntents
+
+/// App intent that retrieves details about a specific terminal.
+struct GetTerminalDetailsIntent: AppIntent {
+ static var title: LocalizedStringResource = "Get Details of Terminal"
+
+ @Parameter(
+ title: "Detail",
+ description: "The detail to extract about a terminal."
+ )
+ var detail: TerminalDetail
+
+ @Parameter(
+ title: "Terminal",
+ description: "The terminal to extract information about."
+ )
+ var terminal: TerminalEntity
+
+ @available(macOS 26.0, *)
+ static var supportedModes: IntentModes = .background
+
+ static var parameterSummary: some ParameterSummary {
+ Summary("Get \(\.$detail) from \(\.$terminal)")
+ }
+
+ @MainActor
+ func perform() async throws -> some IntentResult & ReturnsValue<String?> {
+ guard await requestIntentPermission() else {
+ throw GhosttyIntentError.permissionDenied
+ }
+
+ switch detail {
+ case .title: return .result(value: terminal.title)
+ case .workingDirectory: return .result(value: terminal.workingDirectory)
+ case .allContents:
+ guard let view = terminal.surfaceView else { throw GhosttyIntentError.surfaceNotFound }
+ return .result(value: view.cachedScreenContents.get())
+ case .selectedText:
+ guard let view = terminal.surfaceView else { throw GhosttyIntentError.surfaceNotFound }
+ return .result(value: view.accessibilitySelectedText())
+ case .visibleText:
+ guard let view = terminal.surfaceView else { throw GhosttyIntentError.surfaceNotFound }
+ return .result(value: view.cachedVisibleContents.get())
+ }
+ }
+}
+
+// MARK: TerminalDetail
+
+enum TerminalDetail: String {
+ case title
+ case workingDirectory
+ case allContents
+ case selectedText
+ case visibleText
+}
+
+extension TerminalDetail: AppEnum {
+ static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Terminal Detail")
+
+ static var caseDisplayRepresentations: [Self: DisplayRepresentation] = [
+ .title: .init(title: "Title"),
+ .workingDirectory: .init(title: "Working Directory"),
+ .allContents: .init(title: "Full Contents"),
+ .selectedText: .init(title: "Selected Text"),
+ .visibleText: .init(title: "Visible Text"),
+ ]
+}
diff --git a/macos/Sources/Features/App Intents/GhosttyIntentError.swift b/macos/Sources/Features/App Intents/GhosttyIntentError.swift
new file mode 100644
index 000000000..c52b7a52e
--- /dev/null
+++ b/macos/Sources/Features/App Intents/GhosttyIntentError.swift
@@ -0,0 +1,13 @@
+enum GhosttyIntentError: Error, CustomLocalizedStringResourceConvertible {
+ case appUnavailable
+ case surfaceNotFound
+ case permissionDenied
+
+ var localizedStringResource: LocalizedStringResource {
+ switch self {
+ case .appUnavailable: "The Ghostty app isn't properly initialized."
+ case .surfaceNotFound: "The terminal no longer exists."
+ case .permissionDenied: "Ghostty doesn't allow Shortcuts."
+ }
+ }
+}
diff --git a/macos/Sources/Features/App Intents/InputIntent.swift b/macos/Sources/Features/App Intents/InputIntent.swift
new file mode 100644
index 000000000..17c97fbbb
--- /dev/null
+++ b/macos/Sources/Features/App Intents/InputIntent.swift
@@ -0,0 +1,317 @@
+import AppKit
+import AppIntents
+
+/// App intent to input text in a terminal.
+struct InputTextIntent: AppIntent {
+ static var title: LocalizedStringResource = "Input Text to Terminal"
+
+ @Parameter(
+ title: "Text",
+ description: "The text to input to the terminal. The text will be inputted as if it was pasted.",
+ inputOptions: String.IntentInputOptions(
+ capitalizationType: .none,
+ multiline: true,
+ autocorrect: false,
+ smartQuotes: false,
+ smartDashes: false
+ )
+ )
+ var text: String
+
+ @Parameter(
+ title: "Terminal",
+ description: "The terminal to scope this action to."
+ )
+ var terminal: TerminalEntity
+
+ @available(macOS 26.0, *)
+ static var supportedModes: IntentModes = [.background, .foreground]
+
+ @MainActor
+ func perform() async throws -> some IntentResult {
+ guard await requestIntentPermission() else {
+ throw GhosttyIntentError.permissionDenied
+ }
+
+ guard let surface = terminal.surfaceModel else {
+ throw GhosttyIntentError.surfaceNotFound
+ }
+
+ surface.sendText(text)
+ return .result()
+ }
+}
+
+/// App intent to trigger a keyboard event.
+struct KeyEventIntent: AppIntent {
+ static var title: LocalizedStringResource = "Send Keyboard Event to Terminal"
+ static var description = IntentDescription("Simulate a keyboard event. This will not handle text encoding; use the 'Input Text' action for that.")
+
+ @Parameter(
+ title: "Key",
+ description: "The key to send to the terminal.",
+ default: .enter
+ )
+ var key: Ghostty.Input.Key
+
+ @Parameter(
+ title: "Modifier(s)",
+ description: "The modifiers to send with the key event.",
+ default: []
+ )
+ var mods: [KeyEventMods]
+
+ @Parameter(
+ title: "Event Type",
+ description: "A key press or release.",
+ default: .press
+ )
+ var action: Ghostty.Input.Action
+
+ @Parameter(
+ title: "Terminal",
+ description: "The terminal to scope this action to."
+ )
+ var terminal: TerminalEntity
+
+ @available(macOS 26.0, *)
+ static var supportedModes: IntentModes = [.background, .foreground]
+
+ @MainActor
+ func perform() async throws -> some IntentResult {
+ guard await requestIntentPermission() else {
+ throw GhosttyIntentError.permissionDenied
+ }
+
+ guard let surface = terminal.surfaceModel else {
+ throw GhosttyIntentError.surfaceNotFound
+ }
+
+ // Convert KeyEventMods array to Ghostty.Input.Mods
+ let ghosttyMods = mods.reduce(Ghostty.Input.Mods()) { result, mod in
+ result.union(mod.ghosttyMod)
+ }
+
+ let keyEvent = Ghostty.Input.KeyEvent(
+ key: key,
+ action: action,
+ mods: ghosttyMods
+ )
+ surface.sendKeyEvent(keyEvent)
+
+ return .result()
+ }
+}
+
+// MARK: MouseButtonIntent
+
+/// App intent to trigger a mouse button event.
+struct MouseButtonIntent: AppIntent {
+ static var title: LocalizedStringResource = "Send Mouse Button Event to Terminal"
+
+ @Parameter(
+ title: "Button",
+ description: "The mouse button to press or release.",
+ default: .left
+ )
+ var button: Ghostty.Input.MouseButton
+
+ @Parameter(
+ title: "Action",
+ description: "Whether to press or release the button.",
+ default: .press
+ )
+ var action: Ghostty.Input.MouseState
+
+ @Parameter(
+ title: "Modifier(s)",
+ description: "The modifiers to send with the mouse event.",
+ default: []
+ )
+ var mods: [KeyEventMods]
+
+ @Parameter(
+ title: "Terminal",
+ description: "The terminal to scope this action to."
+ )
+ var terminal: TerminalEntity
+
+ @available(macOS 26.0, *)
+ static var supportedModes: IntentModes = [.background, .foreground]
+
+ @MainActor
+ func perform() async throws -> some IntentResult {
+ guard await requestIntentPermission() else {
+ throw GhosttyIntentError.permissionDenied
+ }
+
+ guard let surface = terminal.surfaceModel else {
+ throw GhosttyIntentError.surfaceNotFound
+ }
+
+ // Convert KeyEventMods array to Ghostty.Input.Mods
+ let ghosttyMods = mods.reduce(Ghostty.Input.Mods()) { result, mod in
+ result.union(mod.ghosttyMod)
+ }
+
+ let mouseEvent = Ghostty.Input.MouseButtonEvent(
+ action: action,
+ button: button,
+ mods: ghosttyMods
+ )
+ surface.sendMouseButton(mouseEvent)
+
+ return .result()
+ }
+}
+
+/// App intent to send a mouse position event.
+struct MousePosIntent: AppIntent {
+ static var title: LocalizedStringResource = "Send Mouse Position Event to Terminal"
+ static var description = IntentDescription("Send a mouse position event to the terminal. This reports the cursor position for mouse tracking.")
+
+ @Parameter(
+ title: "X Position",
+ description: "The horizontal position of the mouse cursor in pixels.",
+ default: 0
+ )
+ var x: Double
+
+ @Parameter(
+ title: "Y Position",
+ description: "The vertical position of the mouse cursor in pixels.",
+ default: 0
+ )
+ var y: Double
+
+ @Parameter(
+ title: "Modifier(s)",
+ description: "The modifiers to send with the mouse position event.",
+ default: []
+ )
+ var mods: [KeyEventMods]
+
+ @Parameter(
+ title: "Terminal",
+ description: "The terminal to scope this action to."
+ )
+ var terminal: TerminalEntity
+
+ @available(macOS 26.0, *)
+ static var supportedModes: IntentModes = [.background, .foreground]
+
+ @MainActor
+ func perform() async throws -> some IntentResult {
+ guard await requestIntentPermission() else {
+ throw GhosttyIntentError.permissionDenied
+ }
+
+ guard let surface = terminal.surfaceModel else {
+ throw GhosttyIntentError.surfaceNotFound
+ }
+
+ // Convert KeyEventMods array to Ghostty.Input.Mods
+ let ghosttyMods = mods.reduce(Ghostty.Input.Mods()) { result, mod in
+ result.union(mod.ghosttyMod)
+ }
+
+ let mousePosEvent = Ghostty.Input.MousePosEvent(
+ x: x,
+ y: y,
+ mods: ghosttyMods
+ )
+ surface.sendMousePos(mousePosEvent)
+
+ return .result()
+ }
+}
+
+/// App intent to send a mouse scroll event.
+struct MouseScrollIntent: AppIntent {
+ static var title: LocalizedStringResource = "Send Mouse Scroll Event to Terminal"
+ static var description = IntentDescription("Send a mouse scroll event to the terminal with configurable precision and momentum.")
+
+ @Parameter(
+ title: "X Scroll Delta",
+ description: "The horizontal scroll amount.",
+ default: 0
+ )
+ var x: Double
+
+ @Parameter(
+ title: "Y Scroll Delta",
+ description: "The vertical scroll amount.",
+ default: 0
+ )
+ var y: Double
+
+ @Parameter(
+ title: "High Precision",
+ description: "Whether this is a high-precision scroll event (e.g., from trackpad).",
+ default: false
+ )
+ var precision: Bool
+
+ @Parameter(
+ title: "Momentum Phase",
+ description: "The momentum phase for inertial scrolling.",
+ default: Ghostty.Input.Momentum.none
+ )
+ var momentum: Ghostty.Input.Momentum
+
+ @Parameter(
+ title: "Terminal",
+ description: "The terminal to scope this action to."
+ )
+ var terminal: TerminalEntity
+
+ @available(macOS 26.0, *)
+ static var supportedModes: IntentModes = [.background, .foreground]
+
+ @MainActor
+ func perform() async throws -> some IntentResult {
+ guard await requestIntentPermission() else {
+ throw GhosttyIntentError.permissionDenied
+ }
+
+ guard let surface = terminal.surfaceModel else {
+ throw GhosttyIntentError.surfaceNotFound
+ }
+
+ let scrollEvent = Ghostty.Input.MouseScrollEvent(
+ x: x,
+ y: y,
+ mods: .init(precision: precision, momentum: momentum)
+ )
+ surface.sendMouseScroll(scrollEvent)
+
+ return .result()
+ }
+}
+
+// MARK: Mods
+
+enum KeyEventMods: String, AppEnum, CaseIterable {
+ case shift
+ case control
+ case option
+ case command
+
+ static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Modifier Key")
+
+ static var caseDisplayRepresentations: [KeyEventMods : DisplayRepresentation] = [
+ .shift: "Shift",
+ .control: "Control",
+ .option: "Option",
+ .command: "Command"
+ ]
+
+ var ghosttyMod: Ghostty.Input.Mods {
+ switch self {
+ case .shift: .shift
+ case .control: .ctrl
+ case .option: .alt
+ case .command: .super
+ }
+ }
+}
diff --git a/macos/Sources/Features/App Intents/IntentPermission.swift b/macos/Sources/Features/App Intents/IntentPermission.swift
new file mode 100644
index 000000000..210d2cb2e
--- /dev/null
+++ b/macos/Sources/Features/App Intents/IntentPermission.swift
@@ -0,0 +1,57 @@
+import AppKit
+
+/// Requests permission for Shortcuts app to interact with Ghostty
+///
+/// This function displays a permission dialog asking the user to allow Shortcuts
+/// to interact with Ghostty. The permission is automatically cached for 10 minutes
+/// if the user selects "Allow", meaning subsequent intent calls won't show the dialog
+/// again during that time period.
+///
+/// The permission uses a shared UserDefaults key across all intents, so granting
+/// permission for one intent allows all Ghostty intents to execute without additional
+/// prompts for the duration of the cache period.
+///
+/// - Returns: `true` if permission is granted, `false` if denied
+///
+/// ## Usage
+/// Add this check at the beginning of any App Intent's `perform()` method:
+/// ```swift
+/// @MainActor
+/// func perform() async throws -> some IntentResult {
+/// guard await requestIntentPermission() else {
+/// throw GhosttyIntentError.permissionDenied
+/// }
+/// // ... continue with intent implementation
+/// }
+/// ```
+func requestIntentPermission() async -> Bool {
+ await withCheckedContinuation { continuation in
+ Task { @MainActor in
+ if let delegate = NSApp.delegate as? AppDelegate {
+ switch (delegate.ghostty.config.macosShortcuts) {
+ case .allow:
+ continuation.resume(returning: true)
+ return
+
+ case .deny:
+ continuation.resume(returning: false)
+ return
+
+ case .ask:
+ // Continue with the permission dialog
+ break
+ }
+ }
+
+
+ PermissionRequest.show(
+ "com.mitchellh.ghostty.shortcutsPermission",
+ message: "Allow Shortcuts to interact with Ghostty?",
+ allowDuration: .forever,
+ rememberDuration: nil,
+ ) { response in
+ continuation.resume(returning: response)
+ }
+ }
+ }
+}
diff --git a/macos/Sources/Features/App Intents/KeybindIntent.swift b/macos/Sources/Features/App Intents/KeybindIntent.swift
new file mode 100644
index 000000000..b31da4a50
--- /dev/null
+++ b/macos/Sources/Features/App Intents/KeybindIntent.swift
@@ -0,0 +1,35 @@
+import AppKit
+import AppIntents
+
+struct KeybindIntent: AppIntent {
+ static var title: LocalizedStringResource = "Invoke a Keybind Action"
+
+ @Parameter(
+ title: "Terminal",
+ description: "The terminal to invoke the action on."
+ )
+ var terminal: TerminalEntity
+
+ @Parameter(
+ title: "Action",
+ description: "The keybind action to invoke. This can be any valid keybind action you could put in a configuration file."
+ )
+ var action: String
+
+ @available(macOS 26.0, *)
+ static var supportedModes: IntentModes = [.background, .foreground]
+
+ @MainActor
+ func perform() async throws -> some IntentResult & ReturnsValue<Bool> {
+ guard await requestIntentPermission() else {
+ throw GhosttyIntentError.permissionDenied
+ }
+
+ guard let surface = terminal.surfaceModel else {
+ throw GhosttyIntentError.surfaceNotFound
+ }
+
+ let performed = surface.perform(action: action)
+ return .result(value: performed)
+ }
+}
diff --git a/macos/Sources/Features/App Intents/NewTerminalIntent.swift b/macos/Sources/Features/App Intents/NewTerminalIntent.swift
new file mode 100644
index 000000000..9b95208bb
--- /dev/null
+++ b/macos/Sources/Features/App Intents/NewTerminalIntent.swift
@@ -0,0 +1,168 @@
+import AppKit
+import AppIntents
+import GhosttyKit
+
+/// App intent that allows creating a new terminal window or tab.
+///
+/// This requires macOS 15 or greater because we use features of macOS 15 here.
+@available(macOS 15.0, *)
+struct NewTerminalIntent: AppIntent {
+ static var title: LocalizedStringResource = "New Terminal"
+ static var description = IntentDescription("Create a new terminal.")
+
+ @Parameter(
+ title: "Location",
+ description: "The location that the terminal should be created.",
+ default: .window
+ )
+ var location: NewTerminalLocation
+
+ @Parameter(
+ title: "Command",
+ description: "Command to execute within your configured shell.",
+ )
+ var command: String?
+
+ @Parameter(
+ title: "Working Directory",
+ description: "The working directory to open in the terminal.",
+ supportedContentTypes: [.folder]
+ )
+ var workingDirectory: IntentFile?
+
+ @Parameter(
+ title: "Environment Variables",
+ description: "Environment variables in `KEY=VALUE` format.",
+ default: []
+ )
+ var env: [String]
+
+ @Parameter(
+ title: "Parent Terminal",
+ description: "The terminal to inherit the base configuration from."
+ )
+ var parent: TerminalEntity?
+
+ @available(macOS 26.0, *)
+ static var supportedModes: IntentModes = .foreground(.immediate)
+
+ @available(macOS, obsoleted: 26.0, message: "Replaced by supportedModes")
+ static var openAppWhenRun = true
+
+ @MainActor
+ func perform() async throws -> some IntentResult & ReturnsValue<TerminalEntity?> {
+ guard await requestIntentPermission() else {
+ throw GhosttyIntentError.permissionDenied
+ }
+ guard let appDelegate = NSApp.delegate as? AppDelegate else {
+ throw GhosttyIntentError.appUnavailable
+ }
+ let ghostty = appDelegate.ghostty
+
+ var config = Ghostty.SurfaceConfiguration()
+
+ // We don't run command as "command" and instead use "initialInput" so
+ // that we can get all the login scripts to setup things like PATH.
+ if let command {
+ config.initialInput = "\(command); exit\n"
+ }
+
+ // If we were given a working directory then open that directory
+ if let url = workingDirectory?.fileURL {
+ let dir = url.hasDirectoryPath ? url : url.deletingLastPathComponent()
+ config.workingDirectory = dir.path(percentEncoded: false)
+ }
+
+ // Parse environment variables from KEY=VALUE format
+ for envVar in env {
+ if let separatorIndex = envVar.firstIndex(of: "=") {
+ let key = String(envVar[..<separatorIndex])
+ let value = String(envVar[envVar.index(after: separatorIndex)...])
+ config.environmentVariables[key] = value
+ }
+ }
+
+ // Determine if we have a parent and get it
+ let parent: Ghostty.SurfaceView?
+ if let parentParam = self.parent {
+ guard let view = parentParam.surfaceView else {
+ throw GhosttyIntentError.surfaceNotFound
+ }
+
+ parent = view
+ } else if let preferred = TerminalController.preferredParent {
+ parent = preferred.focusedSurface ?? preferred.surfaceTree.root?.leftmostLeaf()
+ } else {
+ parent = nil
+ }
+
+ switch location {
+ case .window:
+ let newController = TerminalController.newWindow(
+ ghostty,
+ withBaseConfig: config,
+ withParent: parent?.window)
+ if let view = newController.surfaceTree.root?.leftmostLeaf() {
+ return .result(value: TerminalEntity(view))
+ }
+
+ case .tab:
+ let newController = TerminalController.newTab(
+ ghostty,
+ from: parent?.window,
+ withBaseConfig: config)
+ if let view = newController?.surfaceTree.root?.leftmostLeaf() {
+ return .result(value: TerminalEntity(view))
+ }
+
+ case .splitLeft, .splitRight, .splitUp, .splitDown:
+ guard let parent,
+ let controller = parent.window?.windowController as? BaseTerminalController else {
+ throw GhosttyIntentError.surfaceNotFound
+ }
+
+ if let view = controller.newSplit(
+ at: parent,
+ direction: location.splitDirection!
+ ) {
+ return .result(value: TerminalEntity(view))
+ }
+ }
+
+ return .result(value: .none)
+ }
+}
+
+// MARK: NewTerminalLocation
+
+enum NewTerminalLocation: String {
+ case tab
+ case window
+ case splitLeft = "split:left"
+ case splitRight = "split:right"
+ case splitUp = "split:up"
+ case splitDown = "split:down"
+
+ var splitDirection: SplitTree<Ghostty.SurfaceView>.NewDirection? {
+ switch self {
+ case .splitLeft: return .left
+ case .splitRight: return .right
+ case .splitUp: return .up
+ case .splitDown: return .down
+ default: return nil
+ }
+ }
+}
+
+extension NewTerminalLocation: AppEnum {
+ static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Terminal Location")
+
+ static var caseDisplayRepresentations: [Self: DisplayRepresentation] = [
+ .tab: .init(title: "Tab"),
+ .window: .init(title: "Window"),
+ .splitLeft: .init(title: "Split Left"),
+ .splitRight: .init(title: "Split Right"),
+ .splitUp: .init(title: "Split Up"),
+ .splitDown: .init(title: "Split Down"),
+ ]
+}
diff --git a/macos/Sources/Features/App Intents/QuickTerminalIntent.swift b/macos/Sources/Features/App Intents/QuickTerminalIntent.swift
new file mode 100644
index 000000000..2e6c9850c
--- /dev/null
+++ b/macos/Sources/Features/App Intents/QuickTerminalIntent.swift
@@ -0,0 +1,32 @@
+import AppKit
+import AppIntents
+
+struct QuickTerminalIntent: AppIntent {
+ static var title: LocalizedStringResource = "Open the Quick Terminal"
+ static var description = IntentDescription("Open the Quick Terminal. If it is already open, then do nothing.")
+
+ @available(macOS 26.0, *)
+ static var supportedModes: IntentModes = .background
+
+ @MainActor
+ func perform() async throws -> some IntentResult & ReturnsValue<[TerminalEntity]> {
+ guard await requestIntentPermission() else {
+ throw GhosttyIntentError.permissionDenied
+ }
+
+ guard let delegate = NSApp.delegate as? AppDelegate else {
+ throw GhosttyIntentError.appUnavailable
+ }
+
+ // This is safe to call even if it is already shown.
+ let c = delegate.quickController
+ c.animateIn()
+
+ // Grab all our terminals
+ let terminals = c.surfaceTree.root?.leaves().map {
+ TerminalEntity($0)
+ } ?? []
+
+ return .result(value: terminals)
+ }
+}
diff --git a/macos/Sources/Features/Colorized Ghostty Icon/ColorizedGhosttyIconImage.swift b/macos/Sources/Features/Colorized Ghostty Icon/ColorizedGhosttyIconImage.swift
index 4d522067e..8a461699f 100644
--- a/macos/Sources/Features/Colorized Ghostty Icon/ColorizedGhosttyIconImage.swift
+++ b/macos/Sources/Features/Colorized Ghostty Icon/ColorizedGhosttyIconImage.swift
@@ -4,12 +4,26 @@ extension View {
/// Returns the ghostty icon to use for views.
func ghosttyIconImage() -> Image {
#if os(macOS)
+ // If we have a specific icon set, then use that
if let delegate = NSApplication.shared.delegate as? AppDelegate,
let nsImage = delegate.appIcon {
return Image(nsImage: nsImage)
}
+
+ // Grab the icon from the running application. This is the best way
+ // I've found so far to get the proper icon for our current icon
+ // tinting and so on with macOS Tahoe
+ if let icon = NSRunningApplication.current.icon {
+ return Image(nsImage: icon)
+ }
+
+ // Get our defined application icon image.
+ if let nsImage = NSApp.applicationIconImage {
+ return Image(nsImage: nsImage)
+ }
#endif
+ // Fall back to a static representation
return Image("AppIconImage")
}
}
diff --git a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift
index 57a76dd43..d02828494 100644
--- a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift
+++ b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift
@@ -17,32 +17,19 @@ struct TerminalCommandPaletteView: View {
// The commands available to the command palette.
private var commandOptions: [CommandOption] {
- guard let surface = surfaceView.surface else { return [] }
-
- var ptr: UnsafeMutablePointer<ghostty_command_s>? = nil
- var count: Int = 0
- ghostty_surface_commands(surface, &ptr, &count)
- guard let ptr else { return [] }
-
- let buffer = UnsafeBufferPointer(start: ptr, count: count)
- return Array(buffer).filter { c in
- let key = String(cString: c.action_key)
- switch (key) {
- case "toggle_tab_overview",
- "toggle_window_decorations":
- return false
- default:
- return true
- }
- }.map { c in
- let action = String(cString: c.action)
- return CommandOption(
- title: String(cString: c.title),
- description: String(cString: c.description),
- symbols: ghosttyConfig.keyboardShortcut(for: action)?.keyList
- ) {
- onAction(action)
+ guard let surface = surfaceView.surfaceModel else { return [] }
+ do {
+ return try surface.commands().map { c in
+ return CommandOption(
+ title: c.title,
+ description: c.description,
+ symbols: ghosttyConfig.keyboardShortcut(for: c.action)?.keyList
+ ) {
+ onAction(c.action)
+ }
}
+ } catch {
+ return []
}
}
diff --git a/macos/Sources/Features/Global Keybinds/GlobalEventTap.swift b/macos/Sources/Features/Global Keybinds/GlobalEventTap.swift
index 935c2fb03..ae77535be 100644
--- a/macos/Sources/Features/Global Keybinds/GlobalEventTap.swift
+++ b/macos/Sources/Features/Global Keybinds/GlobalEventTap.swift
@@ -141,12 +141,7 @@ fileprivate func cgEventFlagsChangedHandler(
guard let event: NSEvent = .init(cgEvent: cgEvent) else { return result }
// Build our event input and call ghostty
- var key_ev = ghostty_input_key_s()
- key_ev.action = GHOSTTY_ACTION_PRESS
- key_ev.mods = Ghostty.ghosttyMods(event.modifierFlags)
- key_ev.keycode = UInt32(event.keyCode)
- key_ev.text = nil
- key_ev.composing = false
+ let key_ev = event.ghosttyKeyEvent(GHOSTTY_ACTION_PRESS)
if (ghostty_app_key(ghostty, key_ev)) {
GlobalEventTap.logger.info("global key event handled event=\(event)")
return nil
diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift
index 1abe30da1..3bd8bc18f 100644
--- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift
+++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift
@@ -21,6 +21,14 @@ class QuickTerminalController: BaseTerminalController {
// The active space when the quick terminal was last shown.
private var previousActiveSpace: CGSSpace? = nil
+ /// The window frame saved when the quick terminal's surface tree becomes empty.
+ ///
+ /// This preserves the user's window size and position when all terminal surfaces
+ /// are closed (e.g., via the `exit` command). When a new surface is created,
+ /// the window will be restored to this frame, preventing SwiftUI from resetting
+ /// the window to its default minimum size.
+ private var lastClosedFrame: NSRect? = nil
+
/// Non-nil if we have hidden dock state.
private var hiddenDock: HiddenDock? = nil
@@ -30,11 +38,15 @@ class QuickTerminalController: BaseTerminalController {
init(_ ghostty: Ghostty.App,
position: QuickTerminalPosition = .top,
baseConfig base: Ghostty.SurfaceConfiguration? = nil,
- surfaceTree tree: Ghostty.SplitNode? = nil
+ surfaceTree tree: SplitTree<Ghostty.SurfaceView>? = nil
) {
self.position = position
self.derivedConfig = DerivedConfig(ghostty.config)
- super.init(ghostty, baseConfig: base, surfaceTree: tree)
+
+ // Important detail here: we initialize with an empty surface tree so
+ // that we don't start a terminal process. This gets started when the
+ // first terminal is shown in `animateIn`.
+ super.init(ghostty, baseConfig: base, surfaceTree: .init())
// Setup our notifications for behaviors
let center = NotificationCenter.default
@@ -55,6 +67,12 @@ class QuickTerminalController: BaseTerminalController {
object: nil)
center.addObserver(
self,
+ selector: #selector(closeWindow(_:)),
+ name: .ghosttyCloseWindow,
+ object: nil
+ )
+ center.addObserver(
+ self,
selector: #selector(onNewTab),
name: Ghostty.Notification.ghosttyNewTab,
object: nil)
@@ -185,13 +203,51 @@ class QuickTerminalController: BaseTerminalController {
// MARK: Base Controller Overrides
- override func surfaceTreeDidChange(from: Ghostty.SplitNode?, to: Ghostty.SplitNode?) {
+ override func surfaceTreeDidChange(from: SplitTree<Ghostty.SurfaceView>, to: SplitTree<Ghostty.SurfaceView>) {
super.surfaceTreeDidChange(from: from, to: to)
- // If our surface tree is nil then we animate the window out.
- if (to == nil) {
+ // If our surface tree is nil then we animate the window out. We
+ // defer reinitializing the tree to save some memory here.
+ if to.isEmpty {
animateOut()
+ return
+ }
+
+ // If we're not empty (e.g. this isn't the first set) and we're
+ // not visible, then we animate in. This allows us to show the quick
+ // terminal when things such as undo/redo are done.
+ if !from.isEmpty && !visible {
+ animateIn()
+ return
+ }
+ }
+
+ override func closeSurface(
+ _ node: SplitTree<Ghostty.SurfaceView>.Node,
+ withConfirmation: Bool = true
+ ) {
+ // If this isn't the root then we're dealing with a split closure.
+ if surfaceTree.root != node {
+ super.closeSurface(node, withConfirmation: withConfirmation)
+ return
+ }
+
+ // If this isn't a final leaf then we're dealing with a split closure
+ guard case .leaf(let surface) = node else {
+ super.closeSurface(node, withConfirmation: withConfirmation)
+ return
+ }
+
+ // If its the root, we check if the process exited. If it did,
+ // then we do empty the tree.
+ if surface.processExited {
+ surfaceTree = .init()
+ return
}
+
+ // If its the root then we just animate out. We never actually allow
+ // the surface to fully close.
+ animateOut()
}
// MARK: Methods
@@ -230,17 +286,18 @@ class QuickTerminalController: BaseTerminalController {
// Set previous active space
self.previousActiveSpace = CGSSpace.active()
- // Animate the window in
- animateWindowIn(window: window, from: position)
-
- // If our surface tree is nil then we initialize a new terminal. The surface
- // tree can be nil if for example we run "eixt" in the terminal and force
+ // If our surface tree is empty then we initialize a new terminal. The surface
+ // tree can be empty if for example we run "exit" in the terminal and force
// animate out.
- if (surfaceTree == nil) {
- let leaf: Ghostty.SplitNode.Leaf = .init(ghostty.app!, baseConfig: nil)
- surfaceTree = .leaf(leaf)
- focusedSurface = leaf.surface
+ if surfaceTree.isEmpty,
+ let ghostty_app = ghostty.app {
+ let view = Ghostty.SurfaceView(ghostty_app, baseConfig: nil)
+ surfaceTree = SplitTree(view: view)
+ focusedSurface = view
}
+
+ // Animate the window in
+ animateWindowIn(window: window, from: position)
}
func animateOut() {
@@ -262,6 +319,12 @@ class QuickTerminalController: BaseTerminalController {
private func animateWindowIn(window: NSWindow, from position: QuickTerminalPosition) {
guard let screen = derivedConfig.quickTerminalScreen.screen else { return }
+ // Restore our previous frame if we have one
+ if let lastClosedFrame {
+ window.setFrame(lastClosedFrame, display: false)
+ self.lastClosedFrame = nil
+ }
+
// Move our window off screen to the top
position.setInitial(in: window, on: screen)
@@ -372,6 +435,12 @@ class QuickTerminalController: BaseTerminalController {
}
private func animateWindowOut(window: NSWindow, to position: QuickTerminalPosition) {
+ // Save the current window frame before animating out. This preserves
+ // the user's preferred window size and position for when the quick
+ // terminal is reactivated with a new surface. Without this, SwiftUI
+ // would reset the window to its minimum content size.
+ lastClosedFrame = window.frame
+
// If we hid the dock then we unhide it.
hiddenDock = nil
diff --git a/macos/Sources/Features/Services/ServiceProvider.swift b/macos/Sources/Features/Services/ServiceProvider.swift
index a06e7d151..f60f94211 100644
--- a/macos/Sources/Features/Services/ServiceProvider.swift
+++ b/macos/Sources/Features/Services/ServiceProvider.swift
@@ -5,7 +5,7 @@ class ServiceProvider: NSObject {
static private let errorNoString = NSString(string: "Could not load any text from the clipboard.")
/// The target for an open operation
- enum OpenTarget {
+ private enum OpenTarget {
case tab
case window
}
@@ -15,7 +15,7 @@ class ServiceProvider: NSObject {
userData: String?,
error: AutoreleasingUnsafeMutablePointer<NSString>
) {
- openTerminalFromPasteboard(pasteboard: pasteboard, target: .tab, error: error)
+ openTerminal(from: pasteboard, target: .tab, error: error)
}
@objc func openWindow(
@@ -23,47 +23,39 @@ class ServiceProvider: NSObject {
userData: String?,
error: AutoreleasingUnsafeMutablePointer<NSString>
) {
- openTerminalFromPasteboard(pasteboard: pasteboard, target: .window, error: error)
+ openTerminal(from: pasteboard, target: .window, error: error)
}
- @inline(__always)
- private func openTerminalFromPasteboard(
- pasteboard: NSPasteboard,
+ private func openTerminal(
+ from pasteboard: NSPasteboard,
target: OpenTarget,
error: AutoreleasingUnsafeMutablePointer<NSString>
) {
- guard let objs = pasteboard.readObjects(forClasses: [NSURL.self]) as? [NSURL] else {
+ guard let delegate = NSApp.delegate as? AppDelegate else { return }
+
+ guard let pathURLs = pasteboard.readObjects(forClasses: [NSURL.self]) as? [URL] else {
error.pointee = Self.errorNoString
return
}
- let urlObjects = objs.map { $0 as URL }
-
- openTerminal(urlObjects, target: target)
- }
-
- private func openTerminal(_ urls: [URL], target: OpenTarget) {
- guard let delegateRaw = NSApp.delegate else { return }
- guard let delegate = delegateRaw as? AppDelegate else { return }
- let terminalManager = delegate.terminalManager
- let uniqueCwds: Set<URL> = Set(
- urls.map { url -> URL in
- // We only open in directories.
+ // Build a set of unique directory URLs to open. File paths are truncated
+ // to their directories because that's the only thing we can open.
+ let directoryURLs = Set(
+ pathURLs.map { url -> URL in
url.hasDirectoryPath ? url : url.deletingLastPathComponent()
}
)
- for cwd in uniqueCwds {
- // Build our config
+ for url in directoryURLs {
var config = Ghostty.SurfaceConfiguration()
- config.workingDirectory = cwd.path(percentEncoded: false)
+ config.workingDirectory = url.path(percentEncoded: false)
switch (target) {
case .window:
- terminalManager.newWindow(withBaseConfig: config)
+ _ = TerminalController.newWindow(delegate.ghostty, withBaseConfig: config)
case .tab:
- terminalManager.newTab(withBaseConfig: config)
+ _ = TerminalController.newTab(delegate.ghostty, withBaseConfig: config)
}
}
diff --git a/macos/Sources/Features/Splits/SplitTree.swift b/macos/Sources/Features/Splits/SplitTree.swift
new file mode 100644
index 000000000..b353f6cbe
--- /dev/null
+++ b/macos/Sources/Features/Splits/SplitTree.swift
@@ -0,0 +1,1284 @@
+import AppKit
+
+/// SplitTree represents a tree of views that can be divided.
+struct SplitTree<ViewType: NSView & Codable>: Codable {
+ /// The root of the tree. This can be nil to indicate the tree is empty.
+ let root: Node?
+
+ /// The node that is currently zoomed. A zoomed split is expected to take up the full
+ /// size of the view area where the splits are shown.
+ let zoomed: Node?
+
+ /// A single node in the tree is either a leaf node (a view) or a split (has a
+ /// left/right or top/bottom).
+ indirect enum Node: Codable {
+ case leaf(view: ViewType)
+ case split(Split)
+
+ struct Split: Equatable, Codable {
+ let direction: Direction
+ let ratio: Double
+ let left: Node
+ let right: Node
+ }
+ }
+
+ enum Direction: Codable {
+ case horizontal // Splits are laid out left and right
+ case vertical // Splits are laid out top and bottom
+ }
+
+ /// The path to a specific node in the tree.
+ struct Path {
+ let path: [Component]
+
+ var isEmpty: Bool { path.isEmpty }
+
+ enum Component {
+ case left
+ case right
+ }
+ }
+
+ /// Spatial representation of the split tree. This can be used to better understand
+ /// its physical representation to perform tasks such as navigation.
+ struct Spatial {
+ let slots: [Slot]
+
+ /// A single slot within the spatial mapping of a tree. Note that the bounds are
+ /// _relative_. They can't be mapped to physical pixels because the SplitTree
+ /// isn't aware of actual rendering. But relative to each other the bounds are
+ /// correct.
+ struct Slot {
+ let node: Node
+ let bounds: CGRect
+ }
+
+ /// Direction for spatial navigation within the split tree.
+ enum Direction {
+ case left
+ case right
+ case up
+ case down
+ }
+ }
+
+ enum SplitError: Error {
+ case viewNotFound
+ }
+
+ enum NewDirection {
+ case left
+ case right
+ case down
+ case up
+ }
+
+ /// The direction that focus can move from a node.
+ enum FocusDirection {
+ // Follow a consistent tree-like structure.
+ case previous
+ case next
+
+ // Spatially-aware navigation targets. These take into account the
+ // layout to find the spatially correct node to move to. Spatial navigation
+ // is always from the top-left corner for now.
+ case spatial(Spatial.Direction)
+ }
+}
+
+// MARK: SplitTree
+
+extension SplitTree {
+ var isEmpty: Bool {
+ root == nil
+ }
+
+ /// Returns true if this tree is split.
+ var isSplit: Bool {
+ if case .split = root { true } else { false }
+ }
+
+ init() {
+ self.init(root: nil, zoomed: nil)
+ }
+
+ init(view: ViewType) {
+ self.init(root: .leaf(view: view), zoomed: nil)
+ }
+
+ /// Checks if the tree contains the specified node.
+ ///
+ /// Note that SplitTree implements Sequence on views so there's already a `contains`
+ /// for views too.
+ ///
+ /// - Parameter node: The node to search for in the tree
+ /// - Returns: True if the node exists in the tree, false otherwise
+ func contains(_ node: Node) -> Bool {
+ guard let root else { return false }
+ return root.path(to: node) != nil
+ }
+
+ /// Insert a new view at the given view point by creating a split in the given direction.
+ /// This will always reset the zoomed state of the tree.
+ func insert(view: ViewType, at: ViewType, direction: NewDirection) throws -> Self {
+ guard let root else { throw SplitError.viewNotFound }
+ return .init(
+ root: try root.insert(view: view, at: at, direction: direction),
+ zoomed: nil)
+ }
+
+ /// Remove a node from the tree. If the node being removed is part of a split,
+ /// the sibling node takes the place of the parent split.
+ func remove(_ target: Node) -> Self {
+ guard let root else { return self }
+
+ // If we're removing the root itself, return an empty tree
+ if root == target {
+ return .init(root: nil, zoomed: nil)
+ }
+
+ // Otherwise, try to remove from the tree
+ let newRoot = root.remove(target)
+
+ // Update zoomed if it was the removed node
+ let newZoomed = (zoomed == target) ? nil : zoomed
+
+ return .init(root: newRoot, zoomed: newZoomed)
+ }
+
+ /// Replace a node in the tree with a new node.
+ func replace(node: Node, with newNode: Node) throws -> Self {
+ guard let root else { throw SplitError.viewNotFound }
+
+ // Get the path to the node we want to replace
+ guard let path = root.path(to: node) else {
+ throw SplitError.viewNotFound
+ }
+
+ // Replace the node
+ let newRoot = try root.replaceNode(at: path, with: newNode)
+
+ // Update zoomed if it was the replaced node
+ let newZoomed = (zoomed == node) ? newNode : zoomed
+
+ return .init(root: newRoot, zoomed: newZoomed)
+ }
+
+ /// Find the next view to focus based on the current focused node and direction
+ func focusTarget(for direction: FocusDirection, from currentNode: Node) -> ViewType? {
+ guard let root else { return nil }
+
+ switch direction {
+ case .previous:
+ // For previous, we traverse in order and find the previous leaf from our leftmost
+ let allLeaves = root.leaves()
+ let currentView = currentNode.leftmostLeaf()
+ guard let currentIndex = allLeaves.firstIndex(where: { $0 === currentView }) else {
+ // Shouldn't be possible leftmostLeaf can't return something that doesn't exist!
+ return nil
+ }
+ let index = allLeaves.indexWrapping(before: currentIndex)
+ return allLeaves[index]
+
+ case .next:
+ // For previous, we traverse in order and find the next leaf from our rightmost
+ let allLeaves = root.leaves()
+ let currentView = currentNode.rightmostLeaf()
+ guard let currentIndex = allLeaves.firstIndex(where: { $0 === currentView }) else {
+ return nil
+ }
+ let index = allLeaves.indexWrapping(after: currentIndex)
+ return allLeaves[index]
+
+ case .spatial(let spatialDirection):
+ // Get spatial representation and find best candidate
+ let spatial = root.spatial()
+ let nodes = spatial.slots(in: spatialDirection, from: currentNode)
+
+ // If we have no nodes in the direction specified then we don't do
+ // anything.
+ if nodes.isEmpty {
+ return nil
+ }
+
+ // Extract the view from the best candidate node. The best candidate
+ // node is the closest leaf node. If we have no leaves (impossible?)
+ // just use the first node.
+ let bestNode = nodes.first(where: {
+ if case .leaf = $0.node { return true } else { return false }
+ }) ?? nodes[0]
+ switch bestNode.node {
+ case .leaf(let view):
+ return view
+
+ case .split:
+ // If the best candidate is a split node, use its the leaf/rightmost
+ // depending on our spatial direction.
+ return switch (spatialDirection) {
+ case .up, .left: bestNode.node.leftmostLeaf()
+ case .down, .right: bestNode.node.rightmostLeaf()
+ }
+ }
+ }
+ }
+
+ /// Equalize all splits in the tree so that each split's ratio is based on the
+ /// relative weight (number of leaves) of its children.
+ func equalize() -> Self {
+ guard let root else { return self }
+ let newRoot = root.equalize()
+ return .init(root: newRoot, zoomed: zoomed)
+ }
+
+ /// Resize a node in the tree by the given pixel amount in the specified direction.
+ ///
+ /// This method adjusts the split ratios of the tree to accommodate the requested resize
+ /// operation. For up/down resizing, it finds the nearest parent vertical split and adjusts
+ /// its ratio. For left/right resizing, it finds the nearest parent horizontal split.
+ /// The bounds parameter is used to construct the spatial tree representation which is
+ /// needed to calculate the current pixel dimensions.
+ ///
+ /// This will always reset the zoomed state.
+ ///
+ /// - Parameters:
+ /// - node: The node to resize
+ /// - by: The number of pixels to resize by
+ /// - direction: The direction to resize in (up, down, left, right)
+ /// - bounds: The bounds used to construct the spatial tree representation
+ /// - Returns: A new SplitTree with the adjusted split ratios
+ /// - Throws: SplitError.viewNotFound if the node is not found in the tree or no suitable parent split exists
+ func resize(node: Node, by pixels: UInt16, in direction: Spatial.Direction, with bounds: CGRect) throws -> Self {
+ guard let root else { throw SplitError.viewNotFound }
+
+ // Find the path to the target node
+ guard let path = root.path(to: node) else {
+ throw SplitError.viewNotFound
+ }
+
+ // Determine which type of split we need to find based on resize direction
+ let targetSplitDirection: Direction = switch direction {
+ case .up, .down: .vertical
+ case .left, .right: .horizontal
+ }
+
+ // Find the nearest parent split of the correct type by walking up the path
+ var splitPath: Path?
+ var splitNode: Node?
+
+ for i in stride(from: path.path.count - 1, through: 0, by: -1) {
+ let parentPath = Path(path: Array(path.path.prefix(i)))
+ if let parent = root.node(at: parentPath), case .split(let split) = parent {
+ if split.direction == targetSplitDirection {
+ splitPath = parentPath
+ splitNode = parent
+ break
+ }
+ }
+ }
+
+ guard let splitPath = splitPath,
+ let splitNode = splitNode,
+ case .split(let split) = splitNode else {
+ throw SplitError.viewNotFound
+ }
+
+ // Get current spatial representation to calculate pixel dimensions
+ let spatial = root.spatial(within: bounds.size)
+ guard let splitSlot = spatial.slots.first(where: { $0.node == splitNode }) else {
+ throw SplitError.viewNotFound
+ }
+
+ // Calculate the new ratio based on pixel change
+ let pixelOffset = Double(pixels)
+ let newRatio: Double
+
+ switch (split.direction, direction) {
+ case (.horizontal, .left):
+ // Moving left boundary: decrease left side
+ newRatio = Swift.max(0.1, Swift.min(0.9, split.ratio - (pixelOffset / splitSlot.bounds.width)))
+ case (.horizontal, .right):
+ // Moving right boundary: increase left side
+ newRatio = Swift.max(0.1, Swift.min(0.9, split.ratio + (pixelOffset / splitSlot.bounds.width)))
+ case (.vertical, .up):
+ // Moving top boundary: decrease top side
+ newRatio = Swift.max(0.1, Swift.min(0.9, split.ratio - (pixelOffset / splitSlot.bounds.height)))
+ case (.vertical, .down):
+ // Moving bottom boundary: increase top side
+ newRatio = Swift.max(0.1, Swift.min(0.9, split.ratio + (pixelOffset / splitSlot.bounds.height)))
+ default:
+ // Direction doesn't match split type - shouldn't happen due to earlier logic
+ throw SplitError.viewNotFound
+ }
+
+ // Create new split with adjusted ratio
+ let newSplit = Node.Split(
+ direction: split.direction,
+ ratio: newRatio,
+ left: split.left,
+ right: split.right
+ )
+
+ // Replace the split node with the new one
+ let newRoot = try root.replaceNode(at: splitPath, with: .split(newSplit))
+ return .init(root: newRoot, zoomed: nil)
+ }
+
+ /// Returns the total bounds of the split hierarchy using NSView bounds.
+ /// Ignores x/y coordinates and assumes views are laid out in a perfect grid.
+ /// Also ignores any possible padding between views.
+ /// - Returns: The total width and height needed to contain all views
+ func viewBounds() -> CGSize {
+ guard let root else { return .zero }
+ return root.viewBounds()
+ }
+}
+
+// MARK: SplitTree.Node
+
+extension SplitTree.Node {
+ typealias Node = SplitTree.Node
+ typealias NewDirection = SplitTree.NewDirection
+ typealias SplitError = SplitTree.SplitError
+ typealias Path = SplitTree.Path
+
+ /// Returns the node in the tree that contains the given view.
+ func node(view: ViewType) -> Node? {
+ switch (self) {
+ case .leaf(view):
+ return self
+
+ case .split(let split):
+ if let result = split.left.node(view: view) {
+ return result
+ } else if let result = split.right.node(view: view) {
+ return result
+ }
+
+ return nil
+
+ default:
+ return nil
+ }
+ }
+
+ /// Returns the path to a given node in the tree. If the returned value is nil then the
+ /// node doesn't exist.
+ func path(to node: Self) -> Path? {
+ var components: [Path.Component] = []
+ func search(_ current: Self) -> Bool {
+ if current == node {
+ return true
+ }
+
+ switch current {
+ case .leaf:
+ return false
+
+ case .split(let split):
+ // Try left branch
+ components.append(.left)
+ if search(split.left) {
+ return true
+ }
+ components.removeLast()
+
+ // Try right branch
+ components.append(.right)
+ if search(split.right) {
+ return true
+ }
+ components.removeLast()
+
+ return false
+ }
+ }
+
+ return search(self) ? Path(path: components) : nil
+ }
+
+ /// Returns the node at the given path from this node as root.
+ func node(at path: Path) -> Node? {
+ if path.isEmpty {
+ return self
+ }
+
+ guard case .split(let split) = self else {
+ return nil
+ }
+
+ let component = path.path[0]
+ let remainingPath = Path(path: Array(path.path.dropFirst()))
+
+ switch component {
+ case .left:
+ return split.left.node(at: remainingPath)
+ case .right:
+ return split.right.node(at: remainingPath)
+ }
+ }
+
+ /// Inserts a new view into the split tree by creating a split at the location of an existing view.
+ ///
+ /// This method creates a new split node containing both the existing view and the new view,
+ /// The position of the new view relative to the existing view is determined by the direction parameter.
+ ///
+ /// - Parameters:
+ /// - view: The new view to insert into the tree
+ /// - at: The existing view at whose location the split should be created
+ /// - direction: The direction relative to the existing view where the new view should be placed
+ ///
+ /// - Note: If the existing view (`at`) is not found in the tree, this method does nothing. We should
+ /// maybe throw instead but at the moment we just do nothing.
+ func insert(view: ViewType, at: ViewType, direction: NewDirection) throws -> Self {
+ // Get the path to our insertion point. If it doesn't exist we do
+ // nothing.
+ guard let path = path(to: .leaf(view: at)) else {
+ throw SplitError.viewNotFound
+ }
+
+ // Determine split direction and which side the new view goes on
+ let splitDirection: SplitTree.Direction
+ let newViewOnLeft: Bool
+ switch direction {
+ case .left:
+ splitDirection = .horizontal
+ newViewOnLeft = true
+ case .right:
+ splitDirection = .horizontal
+ newViewOnLeft = false
+ case .up:
+ splitDirection = .vertical
+ newViewOnLeft = true
+ case .down:
+ splitDirection = .vertical
+ newViewOnLeft = false
+ }
+
+ // Create the new split node
+ let newNode: Node = .leaf(view: view)
+ let existingNode: Node = .leaf(view: at)
+ let newSplit: Node = .split(.init(
+ direction: splitDirection,
+ ratio: 0.5,
+ left: newViewOnLeft ? newNode : existingNode,
+ right: newViewOnLeft ? existingNode : newNode
+ ))
+
+ // Replace the node at the path with the new split
+ return try replaceNode(at: path, with: newSplit)
+ }
+
+ /// Helper function to replace a node at the given path from the root
+ func replaceNode(at path: Path, with newNode: Self) throws -> Self {
+ // If path is empty, replace the root
+ if path.isEmpty {
+ return newNode
+ }
+
+ // Otherwise, we need to replace the proper left/right all along
+ // the way since Node is a value type (enum). To do that, we need
+ // recursion. We can't use a simple iterative approach because we
+ // can't update in-place.
+ func replaceInner(current: Node, pathOffset: Int) throws -> Node {
+ // Base case: if we've consumed the entire path, replace this node
+ if pathOffset >= path.path.count {
+ return newNode
+ }
+
+ // We need to go deeper, so current must be a split for the path
+ // to be valid. Otherwise, the path is invalid.
+ guard case .split(let split) = current else {
+ throw SplitError.viewNotFound
+ }
+
+ let component = path.path[pathOffset]
+ switch component {
+ case .left:
+ return .split(.init(
+ direction: split.direction,
+ ratio: split.ratio,
+ left: try replaceInner(current: split.left, pathOffset: pathOffset + 1),
+ right: split.right
+ ))
+ case .right:
+ return .split(.init(
+ direction: split.direction,
+ ratio: split.ratio,
+ left: split.left,
+ right: try replaceInner(current: split.right, pathOffset: pathOffset + 1)
+ ))
+ }
+ }
+
+ return try replaceInner(current: self, pathOffset: 0)
+ }
+
+ /// Remove a node from the tree. Returns the modified tree, or nil if removing
+ /// the node results in an empty tree.
+ func remove(_ target: Node) -> Node? {
+ // If we're removing ourselves, return nil
+ if self == target {
+ return nil
+ }
+
+ switch self {
+ case .leaf:
+ // A leaf that isn't the target stays as is
+ return self
+
+ case .split(let split):
+ // Neither child is directly the target, so we need to recursively
+ // try to remove from both children
+ let newLeft = split.left.remove(target)
+ let newRight = split.right.remove(target)
+
+ // If both are nil then we remove everything. This shouldn't ever
+ // happen because duplicate nodes shouldn't exist, but we want to
+ // be robust against it.
+ if newLeft == nil && newRight == nil {
+ return nil
+ } else if newLeft == nil {
+ return newRight
+ } else if newRight == nil {
+ return newLeft
+ }
+
+ // Both children still exist after removal
+ return .split(.init(
+ direction: split.direction,
+ ratio: split.ratio,
+ left: newLeft!,
+ right: newRight!
+ ))
+ }
+ }
+
+ /// Resize a split node to the specified ratio.
+ /// For leaf nodes, this returns the node unchanged.
+ /// For split nodes, this creates a new split with the updated ratio.
+ func resize(to ratio: Double) -> Self {
+ switch self {
+ case .leaf:
+ // Leaf nodes don't have a ratio to resize
+ return self
+
+ case .split(let split):
+ // Create a new split with the updated ratio
+ return .split(.init(
+ direction: split.direction,
+ ratio: ratio,
+ left: split.left,
+ right: split.right
+ ))
+ }
+ }
+
+ /// Get the leftmost leaf in this subtree
+ func leftmostLeaf() -> ViewType {
+ switch self {
+ case .leaf(let view):
+ return view
+ case .split(let split):
+ return split.left.leftmostLeaf()
+ }
+ }
+
+ /// Get the rightmost leaf in this subtree
+ func rightmostLeaf() -> ViewType {
+ switch self {
+ case .leaf(let view):
+ return view
+ case .split(let split):
+ return split.right.rightmostLeaf()
+ }
+ }
+
+ /// Equalize this node and all its children, returning a new node with splits
+ /// adjusted so that each split's ratio is based on the relative weight
+ /// (number of leaves) of its children.
+ func equalize() -> Node {
+ let (equalizedNode, _) = equalizeWithWeight()
+ return equalizedNode
+ }
+
+ /// Internal helper that equalizes and returns both the node and its weight.
+ private func equalizeWithWeight() -> (node: Node, weight: Int) {
+ switch self {
+ case .leaf:
+ // A leaf has weight 1 and doesn't change
+ return (self, 1)
+
+ case .split(let split):
+ // 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,
+ ratio: newRatio,
+ left: leftNode,
+ right: rightNode
+ )
+
+ return (.split(newSplit), totalWeight)
+ }
+ }
+
+ /// 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 {
+ case .leaf(let view):
+ return [(view, bounds)]
+
+ case .split(let split):
+ // Calculate bounds for left and right based on split direction and ratio
+ let leftBounds: CGRect
+ let rightBounds: CGRect
+
+ switch split.direction {
+ case .horizontal:
+ // Split horizontally: left | right
+ let splitX = bounds.minX + bounds.width * split.ratio
+ leftBounds = CGRect(
+ x: bounds.minX,
+ y: bounds.minY,
+ width: bounds.width * split.ratio,
+ height: bounds.height
+ )
+ rightBounds = CGRect(
+ x: splitX,
+ y: bounds.minY,
+ width: bounds.width * (1 - split.ratio),
+ height: bounds.height
+ )
+
+ case .vertical:
+ // Split vertically: top / bottom
+ // Note: In our normalized coordinate system, Y increases upward
+ let splitY = bounds.minY + bounds.height * split.ratio
+ leftBounds = CGRect(
+ x: bounds.minX,
+ y: splitY,
+ width: bounds.width,
+ height: bounds.height * (1 - split.ratio)
+ )
+ rightBounds = CGRect(
+ x: bounds.minX,
+ y: bounds.minY,
+ width: bounds.width,
+ height: bounds.height * split.ratio
+ )
+ }
+
+ // Recursively calculate bounds for children
+ return split.left.calculateViewBounds(in: leftBounds) +
+ split.right.calculateViewBounds(in: rightBounds)
+ }
+ }
+
+ /// Returns the total bounds of this subtree using NSView bounds.
+ /// Ignores x/y coordinates and assumes views are laid out in a perfect grid.
+ /// - Returns: The total width and height needed to contain all views in this subtree
+ func viewBounds() -> CGSize {
+ switch self {
+ case .leaf(let view):
+ return view.bounds.size
+
+ case .split(let split):
+ let leftBounds = split.left.viewBounds()
+ let rightBounds = split.right.viewBounds()
+
+ switch split.direction {
+ case .horizontal:
+ // Horizontal split: width is sum, height is max
+ return CGSize(
+ width: leftBounds.width + rightBounds.width,
+ height: Swift.max(leftBounds.height, rightBounds.height)
+ )
+
+ case .vertical:
+ // Vertical split: height is sum, width is max
+ return CGSize(
+ width: Swift.max(leftBounds.width, rightBounds.width),
+ height: leftBounds.height + rightBounds.height
+ )
+ }
+ }
+ }
+}
+
+// MARK: SplitTree.Node Spatial
+
+extension SplitTree.Node {
+ /// Returns the spatial representation of this node and its subtree.
+ ///
+ /// This method creates a `Spatial` representation that maps the logical split tree structure
+ /// to 2D coordinate space. The coordinate system uses (0,0) as the top-left corner with
+ /// positive X extending right and positive Y extending down.
+ ///
+ /// The spatial representation provides:
+ /// - Relative bounds for each node based on split ratios
+ /// - Grid-like dimensions where each split adds 1 to the column/row count
+ /// - Accurate positioning that reflects the actual layout structure
+ ///
+ /// The bounds are pixel perfect based on assuming that each row and column are 1 pixel
+ /// tall or wide, respectively. This needs to be scaled up to the proper bounds for a real
+ /// layout.
+ ///
+ /// Example:
+ /// ```
+ /// // For a layout like:
+ /// // +--------+----+
+ /// // | A | B |
+ /// // +--------+----+
+ /// // | C | D |
+ /// // +--------+----+
+ /// //
+ /// // The spatial representation would have:
+ /// // - Total dimensions: (width: 2, height: 2)
+ /// // - Node bounds based on actual split ratios
+ /// ```
+ ///
+ /// - Parameter bounds: Optional size constraints for the spatial representation. If nil, uses artificial dimensions based
+ /// on grid layout
+ /// - Returns: A `Spatial` struct containing all slots with their calculated bounds
+ func spatial(within bounds: CGSize? = nil) -> SplitTree.Spatial {
+ // If we're not given bounds, we use artificial dimensions based on
+ // the total width/height in columns/rows.
+ let width: Double
+ let height: Double
+ if let bounds {
+ width = bounds.width
+ height = bounds.height
+ } else {
+ let (w, h) = self.dimensions()
+ width = Double(w)
+ height = Double(h)
+ }
+
+ // Calculate slots with relative bounds
+ let slots = spatialSlots(in: CGRect(x: 0, y: 0, width: width, height: height))
+ return SplitTree.Spatial(slots: slots)
+ }
+
+ /// Calculates the grid dimensions (columns and rows) needed to represent this subtree.
+ ///
+ /// This method recursively analyzes the split tree structure to determine how many
+ /// columns and rows are needed to represent the layout in a 2D grid. Each leaf node
+ /// occupies one grid cell (1×1), and each split extends the grid in one direction:
+ ///
+ /// - **Horizontal splits**: Add columns (increase width)
+ /// - **Vertical splits**: Add rows (increase height)
+ ///
+ /// The calculation rules are:
+ /// - **Leaf nodes**: Always (1, 1) - one column, one row
+ /// - **Horizontal splits**: Width = sum of children widths, Height = max of children heights
+ /// - **Vertical splits**: Width = max of children widths, Height = sum of children heights
+ ///
+ /// Example:
+ /// ```
+ /// // Single leaf: (1, 1)
+ /// // Horizontal split with 2 leaves: (2, 1)
+ /// // Vertical split with 2 leaves: (1, 2)
+ /// // Complex layout with both: (2, 2) or larger
+ /// ```
+ ///
+ /// - Returns: A tuple containing (width: columns, height: rows) as unsigned integers
+ private func dimensions() -> (width: UInt, height: UInt) {
+ switch self {
+ case .leaf:
+ return (1, 1)
+
+ case .split(let split):
+ let leftDimensions = split.left.dimensions()
+ let rightDimensions = split.right.dimensions()
+
+ switch split.direction {
+ case .horizontal:
+ // Horizontal split: width is sum, height is max
+ return (
+ width: leftDimensions.width + rightDimensions.width,
+ height: Swift.max(leftDimensions.height, rightDimensions.height)
+ )
+
+ case .vertical:
+ // Vertical split: height is sum, width is max
+ return (
+ width: Swift.max(leftDimensions.width, rightDimensions.width),
+ height: leftDimensions.height + rightDimensions.height
+ )
+ }
+ }
+ }
+
+ /// Calculates the spatial slots (nodes with bounds) for this subtree within the given bounds.
+ ///
+ /// This method recursively traverses the split tree and calculates the precise bounds
+ /// for each node based on the split ratios and directions. The bounds are calculated
+ /// relative to the provided bounds rectangle.
+ ///
+ /// The calculation process:
+ /// 1. **Leaf nodes**: Create a single slot with the provided bounds
+ /// 2. **Split nodes**:
+ /// - Divide the bounds according to the split ratio and direction
+ /// - Create a slot for the split node itself
+ /// - Recursively calculate slots for both children
+ /// - Return all slots combined
+ ///
+ /// Split ratio interpretation:
+ /// - **Horizontal splits**: Ratio determines left/right width distribution
+ /// - Left child gets `ratio * width`
+ /// - Right child gets `(1 - ratio) * width`
+ /// - **Vertical splits**: Ratio determines top/bottom height distribution
+ /// - Top (left) child gets `ratio * height`
+ /// - Bottom (right) child gets `(1 - ratio) * height`
+ ///
+ /// Coordinate system: (0,0) is top-left, positive X goes right, positive Y goes down.
+ ///
+ /// - Parameter bounds: The bounding rectangle to subdivide for this subtree
+ /// - Returns: An array of `Spatial.Slot` objects, each containing a node and its bounds
+ private func spatialSlots(in bounds: CGRect) -> [SplitTree.Spatial.Slot] {
+ switch self {
+ case .leaf:
+ // A leaf takes up our full bounds.
+ return [.init(node: self, bounds: bounds)]
+
+ case .split(let split):
+ let leftBounds: CGRect
+ let rightBounds: CGRect
+
+ switch split.direction {
+ case .horizontal:
+ // Split horizontally: left | right using the ratio
+ let splitX = bounds.minX + bounds.width * split.ratio
+ leftBounds = CGRect(
+ x: bounds.minX,
+ y: bounds.minY,
+ width: bounds.width * split.ratio,
+ height: bounds.height
+ )
+ rightBounds = CGRect(
+ x: splitX,
+ y: bounds.minY,
+ width: bounds.width * (1 - split.ratio),
+ height: bounds.height
+ )
+
+ case .vertical:
+ // Split vertically: top / bottom using the ratio
+ // Top-left is (0,0), so top (left) gets the upper portion
+ let splitY = bounds.minY + bounds.height * split.ratio
+ leftBounds = CGRect(
+ x: bounds.minX,
+ y: bounds.minY,
+ width: bounds.width,
+ height: bounds.height * split.ratio
+ )
+ rightBounds = CGRect(
+ x: bounds.minX,
+ y: splitY,
+ width: bounds.width,
+ height: bounds.height * (1 - split.ratio)
+ )
+ }
+
+ // Recursively calculate slots for children and include a slot for this split
+ var slots: [SplitTree.Spatial.Slot] = [.init(node: self, bounds: bounds)]
+ slots += split.left.spatialSlots(in: leftBounds)
+ slots += split.right.spatialSlots(in: rightBounds)
+
+ return slots
+ }
+ }
+}
+
+// MARK: SplitTree.Spatial
+
+extension SplitTree.Spatial {
+ /// Returns all slots in the specified direction relative to the reference node.
+ ///
+ /// This method finds all slots positioned in the given direction from the reference node:
+ /// - **Left**: Slots with bounds to the left of the reference node
+ /// - **Right**: Slots with bounds to the right of the reference node
+ /// - **Up**: Slots with bounds above the reference node (Y=0 is top)
+ /// - **Down**: Slots with bounds below the reference node
+ ///
+ /// Results are sorted by 2D euclidean distance from the reference node, with closest slots first.
+ /// Distance is calculated from the top-left corners of the bounds, prioritizing nodes that are
+ /// closer in both dimensions.
+ ///
+ /// **Important**: The returned array contains both split nodes and leaf nodes. When using this
+ /// for navigation or focus management, you typically want to filter for leaf nodes first, as they
+ /// represent the actual views that can receive focus. Split nodes are included in the results
+ /// because they have bounds and occupy space in the layout, but they are structural elements
+ /// that cannot themselves be focused. If no leaf nodes are found in the results, you may need
+ /// to traverse into a split node to find its appropriate leaf child.
+ ///
+ /// - Parameters:
+ /// - direction: The direction to search for slots
+ /// - referenceNode: The node to use as the reference point
+ /// - Returns: An array of slots in the specified direction, sorted by 2D distance (closest first)
+ func slots(in direction: Direction, from referenceNode: SplitTree.Node) -> [Slot] {
+ guard let refSlot = slots.first(where: { $0.node == referenceNode }) else { return [] }
+
+ // Helper function to calculate 2D euclidean distance between top-left corners of two rectangles
+ func distance(from rect1: CGRect, to rect2: CGRect) -> Double {
+ // Calculate distance between top-left corners
+ let dx = rect2.minX - rect1.minX
+ let dy = rect2.minY - rect1.minY
+ return sqrt(dx * dx + dy * dy)
+ }
+
+ let result = switch direction {
+ case .left:
+ // Slots to the left: their right edge is at or left of reference's left edge
+ slots.filter {
+ $0.node != referenceNode && $0.bounds.maxX <= refSlot.bounds.minX
+ }.sorted {
+ distance(from: refSlot.bounds, to: $0.bounds) < distance(from: refSlot.bounds, to: $1.bounds)
+ }
+
+ case .right:
+ // Slots to the right: their left edge is at or right of reference's right edge
+ slots.filter {
+ $0.node != referenceNode && $0.bounds.minX >= refSlot.bounds.maxX
+ }.sorted {
+ distance(from: refSlot.bounds, to: $0.bounds) < distance(from: refSlot.bounds, to: $1.bounds)
+ }
+
+ case .up:
+ // Slots above: their bottom edge is at or above reference's top edge
+ slots.filter {
+ $0.node != referenceNode && $0.bounds.maxY <= refSlot.bounds.minY
+ }.sorted {
+ distance(from: refSlot.bounds, to: $0.bounds) < distance(from: refSlot.bounds, to: $1.bounds)
+ }
+
+ case .down:
+ // Slots below: their top edge is at or below reference's bottom edge
+ slots.filter {
+ $0.node != referenceNode && $0.bounds.minY >= refSlot.bounds.maxY
+ }.sorted {
+ distance(from: refSlot.bounds, to: $0.bounds) < distance(from: refSlot.bounds, to: $1.bounds)
+ }
+ }
+
+ return result
+ }
+
+ /// Returns whether the given node borders the specified side of the spatial bounds.
+ ///
+ /// This method checks if a node's bounds touch the edge of the overall spatial area:
+ /// - **Up**: Node's top edge touches the top of the spatial area (Y=0)
+ /// - **Down**: Node's bottom edge touches the bottom of the spatial area (Y=maxY)
+ /// - **Left**: Node's left edge touches the left of the spatial area (X=0)
+ /// - **Right**: Node's right edge touches the right of the spatial area (X=maxX)
+ ///
+ /// - Parameters:
+ /// - side: The side of the spatial bounds to check
+ /// - node: The node to check if it borders the specified side
+ /// - Returns: True if the node borders the specified side, false otherwise
+ func doesBorder(side: Direction, from node: SplitTree.Node) -> Bool {
+ // Find the slot for this node
+ guard let slot = slots.first(where: { $0.node == node }) else { return false }
+
+ // Calculate the overall bounds of all slots
+ let overallBounds = slots.reduce(CGRect.null) { result, slot in
+ result.union(slot.bounds)
+ }
+
+ return switch side {
+ case .up:
+ slot.bounds.minY == overallBounds.minY
+ case .down:
+ slot.bounds.maxY == overallBounds.maxY
+ case .left:
+ slot.bounds.minX == overallBounds.minX
+ case .right:
+ slot.bounds.maxX == overallBounds.maxX
+ }
+ }
+}
+
+// MARK: SplitTree.Node Protocols
+
+extension SplitTree.Node: Equatable {
+ static func == (lhs: Self, rhs: Self) -> Bool {
+ switch (lhs, rhs) {
+ case let (.leaf(leftView), .leaf(rightView)):
+ // Compare NSView instances by object identity
+ return leftView === rightView
+
+ case let (.split(split1), .split(split2)):
+ return split1 == split2
+
+ default:
+ return false
+ }
+ }
+}
+
+// MARK: SplitTree Codable
+
+extension SplitTree.Node {
+ enum CodingKeys: String, CodingKey {
+ case view
+ case split
+ }
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+
+ if container.contains(.view) {
+ let view = try container.decode(ViewType.self, forKey: .view)
+ self = .leaf(view: view)
+ } else if container.contains(.split) {
+ let split = try container.decode(Split.self, forKey: .split)
+ self = .split(split)
+ } else {
+ throw DecodingError.dataCorrupted(
+ DecodingError.Context(
+ codingPath: decoder.codingPath,
+ debugDescription: "No valid node type found"
+ )
+ )
+ }
+ }
+
+ func encode(to encoder: Encoder) throws {
+ var container = encoder.container(keyedBy: CodingKeys.self)
+
+ switch self {
+ case .leaf(let view):
+ try container.encode(view, forKey: .view)
+
+ case .split(let split):
+ try container.encode(split, forKey: .split)
+ }
+ }
+}
+
+// MARK: SplitTree Sequences
+
+extension SplitTree.Node {
+ /// Returns all leaf views in this subtree
+ func leaves() -> [ViewType] {
+ switch self {
+ case .leaf(let view):
+ return [view]
+
+ case .split(let split):
+ return split.left.leaves() + split.right.leaves()
+ }
+ }
+}
+
+extension SplitTree: Sequence {
+ func makeIterator() -> [ViewType].Iterator {
+ return root?.leaves().makeIterator() ?? [].makeIterator()
+ }
+}
+
+extension SplitTree.Node: Sequence {
+ func makeIterator() -> [ViewType].Iterator {
+ return leaves().makeIterator()
+ }
+}
+
+// MARK: SplitTree Collection
+
+extension SplitTree: Collection {
+ typealias Index = Int
+ typealias Element = ViewType
+
+ var startIndex: Int {
+ return 0
+ }
+
+ var endIndex: Int {
+ return root?.leaves().count ?? 0
+ }
+
+ subscript(position: Int) -> ViewType {
+ precondition(position >= 0 && position < endIndex, "Index out of bounds")
+ let leaves = root?.leaves() ?? []
+ return leaves[position]
+ }
+
+ func index(after i: Int) -> Int {
+ precondition(i < endIndex, "Cannot increment index beyond endIndex")
+ return i + 1
+ }
+}
+
+// MARK: Structural Identity
+
+extension SplitTree.Node {
+ /// Returns a hashable representation that captures this node's structural identity.
+ var structuralIdentity: StructuralIdentity {
+ StructuralIdentity(self)
+ }
+
+ /// A hashable representation of a node that captures its structural identity.
+ ///
+ /// This type provides a way to track changes to a node's structure in SwiftUI
+ /// by implementing `Hashable` based on:
+ /// - The node's hierarchical structure (splits and their directions)
+ /// - The identity of view instances in leaf nodes (using object identity)
+ /// - The split directions (but not ratios, as those may change slightly)
+ ///
+ /// This is useful for SwiftUI's `id()` modifier to detect when a node's structure
+ /// has changed, triggering appropriate view updates while preserving view identity
+ /// for unchanged portions of the tree.
+ struct StructuralIdentity: Hashable {
+ private let node: SplitTree.Node
+
+ init(_ node: SplitTree.Node) {
+ self.node = node
+ }
+
+ static func == (lhs: Self, rhs: Self) -> Bool {
+ lhs.node.isStructurallyEqual(to: rhs.node)
+ }
+
+ func hash(into hasher: inout Hasher) {
+ node.hashStructure(into: &hasher)
+ }
+ }
+
+ /// Checks if this node is structurally equal to another node.
+ /// Two nodes are structurally equal if they have the same tree structure
+ /// and the same views (by identity) in the same positions.
+ fileprivate func isStructurallyEqual(to other: Node) -> Bool {
+ switch (self, other) {
+ case let (.leaf(view1), .leaf(view2)):
+ // Views must be the same instance
+ return view1 === view2
+
+ case let (.split(split1), .split(split2)):
+ // Splits must have same direction and structurally equal children
+ // Note: We intentionally don't compare ratios as they may change slightly
+ return split1.direction == split2.direction &&
+ split1.left.isStructurallyEqual(to: split2.left) &&
+ split1.right.isStructurallyEqual(to: split2.right)
+
+ default:
+ // Different node types
+ return false
+ }
+ }
+
+ /// Hash keys for structural identity
+ private enum HashKey: UInt8 {
+ case leaf = 0
+ case split = 1
+ }
+
+ /// Hashes the structural identity of this node.
+ /// Includes the tree structure and view identities in the hash.
+ fileprivate func hashStructure(into hasher: inout Hasher) {
+ switch self {
+ case .leaf(let view):
+ hasher.combine(HashKey.leaf)
+ hasher.combine(ObjectIdentifier(view))
+
+ case .split(let split):
+ hasher.combine(HashKey.split)
+ hasher.combine(split.direction)
+ // Note: We intentionally don't hash the ratio
+ split.left.hashStructure(into: &hasher)
+ split.right.hashStructure(into: &hasher)
+ }
+ }
+}
+
+extension SplitTree {
+ /// Returns a hashable representation that captures this tree's structural identity.
+ var structuralIdentity: StructuralIdentity {
+ StructuralIdentity(self)
+ }
+
+ /// A hashable representation of a SplitTree that captures its structural identity.
+ ///
+ /// This type provides a way to track changes to a SplitTree's structure in SwiftUI
+ /// by implementing `Hashable` based on:
+ /// - The tree's hierarchical structure (splits and their directions)
+ /// - The identity of view instances in leaf nodes (using object identity)
+ /// - The zoomed node state (if any)
+ ///
+ /// This is useful for SwiftUI's `id()` modifier to detect when a tree's structure
+ /// has changed, triggering appropriate view updates while preserving view identity
+ /// for unchanged portions of the tree.
+ ///
+ /// Example usage:
+ /// ```swift
+ /// var body: some View {
+ /// SplitTreeView(tree: splitTree)
+ /// .id(splitTree.structuralIdentity)
+ /// }
+ /// ```
+ struct StructuralIdentity: Hashable {
+ private let root: Node?
+ private let zoomed: Node?
+
+ init(_ tree: SplitTree) {
+ self.root = tree.root
+ self.zoomed = tree.zoomed
+ }
+
+ static func == (lhs: Self, rhs: Self) -> Bool {
+ areNodesStructurallyEqual(lhs.root, rhs.root) &&
+ areNodesStructurallyEqual(lhs.zoomed, rhs.zoomed)
+ }
+
+ func hash(into hasher: inout Hasher) {
+ hasher.combine(0) // Tree marker
+ if let root = root {
+ root.hashStructure(into: &hasher)
+ }
+ hasher.combine(1) // Zoomed marker
+ if let zoomed = zoomed {
+ zoomed.hashStructure(into: &hasher)
+ }
+ }
+
+ /// Helper to compare optional nodes for structural equality
+ private static func areNodesStructurallyEqual(_ lhs: Node?, _ rhs: Node?) -> Bool {
+ switch (lhs, rhs) {
+ case (nil, nil):
+ return true
+ case let (node1?, node2?):
+ return node1.isStructurallyEqual(to: node2)
+ default:
+ return false
+ }
+ }
+ }
+}
diff --git a/macos/Sources/Helpers/SplitView/SplitView.Divider.swift b/macos/Sources/Features/Splits/SplitView.Divider.swift
index 83847ff0c..a01175dce 100644
--- a/macos/Sources/Helpers/SplitView/SplitView.Divider.swift
+++ b/macos/Sources/Features/Splits/SplitView.Divider.swift
@@ -7,6 +7,7 @@ extension SplitView {
let visibleSize: CGFloat
let invisibleSize: CGFloat
let color: Color
+ @Binding var split: CGFloat
private var visibleWidth: CGFloat? {
switch (direction) {
@@ -79,6 +80,40 @@ extension SplitView {
NSCursor.pop()
}
}
+ .accessibilityElement(children: .ignore)
+ .accessibilityLabel(axLabel)
+ .accessibilityValue("\(Int(split * 100))%")
+ .accessibilityHint(axHint)
+ .accessibilityAddTraits(.isButton)
+ .accessibilityAdjustableAction { direction in
+ let adjustment: CGFloat = 0.025
+ switch direction {
+ case .increment:
+ split = min(split + adjustment, 0.9)
+ case .decrement:
+ split = max(split - adjustment, 0.1)
+ @unknown default:
+ break
+ }
+ }
+ }
+
+ private var axLabel: String {
+ switch direction {
+ case .horizontal:
+ return "Horizontal split divider"
+ case .vertical:
+ return "Vertical split divider"
+ }
+ }
+
+ private var axHint: String {
+ switch direction {
+ case .horizontal:
+ return "Drag to resize the left and right panes"
+ case .vertical:
+ return "Drag to resize the top and bottom panes"
+ }
}
}
}
diff --git a/macos/Sources/Helpers/SplitView/SplitView.swift b/macos/Sources/Features/Splits/SplitView.swift
index 8ac2bc33f..3dc3c36a3 100644
--- a/macos/Sources/Helpers/SplitView/SplitView.swift
+++ b/macos/Sources/Features/Splits/SplitView.swift
@@ -1,5 +1,4 @@
import SwiftUI
-import Combine
/// A split view shows a left and right (or top and bottom) view with a divider in the middle to do resizing.
/// The terminlogy "left" and "right" is always used but for vertical splits "left" is "top" and "right" is "bottom".
@@ -13,12 +12,10 @@ struct SplitView<L: View, R: View>: View {
/// Divider color
let dividerColor: Color
- /// If set, the split view supports programmatic resizing via events sent via the publisher.
/// Minimum increment (in points) that this split can be resized by, in
/// each direction. Both `height` and `width` should be whole numbers
/// greater than or equal to 1.0
let resizeIncrements: NSSize
- let resizePublisher: PassthroughSubject<Double, Never>
/// The left and right views to render.
let left: L
@@ -45,47 +42,32 @@ struct SplitView<L: View, R: View>: View {
left
.frame(width: leftRect.size.width, height: leftRect.size.height)
.offset(x: leftRect.origin.x, y: leftRect.origin.y)
+ .accessibilityElement(children: .contain)
+ .accessibilityLabel(leftPaneLabel)
right
.frame(width: rightRect.size.width, height: rightRect.size.height)
.offset(x: rightRect.origin.x, y: rightRect.origin.y)
+ .accessibilityElement(children: .contain)
+ .accessibilityLabel(rightPaneLabel)
Divider(direction: direction,
visibleSize: splitterVisibleSize,
invisibleSize: splitterInvisibleSize,
- color: dividerColor)
+ color: dividerColor,
+ split: $split)
.position(splitterPoint)
.gesture(dragGesture(geo.size, splitterPoint: splitterPoint))
}
- .onReceive(resizePublisher) { value in
- resize(for: geo.size, amount: value)
- }
+ .accessibilityElement(children: .contain)
+ .accessibilityLabel(splitViewLabel)
}
}
- /// Initialize a split view. This view isn't programmatically resizable; it can only be resized
- /// by manually dragging the divider.
- init(_ direction: SplitViewDirection,
- _ split: Binding<CGFloat>,
- dividerColor: Color,
- @ViewBuilder left: (() -> L),
- @ViewBuilder right: (() -> R)) {
- self.init(
- direction,
- split,
- dividerColor: dividerColor,
- resizeIncrements: .init(width: 1, height: 1),
- resizePublisher: .init(),
- left: left,
- right: right
- )
- }
-
- /// Initialize a split view that supports programmatic resizing.
+ /// Initialize a split view that can be resized by manually dragging the divider.
init(
_ direction: SplitViewDirection,
_ split: Binding<CGFloat>,
dividerColor: Color,
- resizeIncrements: NSSize,
- resizePublisher: PassthroughSubject<Double, Never>,
+ resizeIncrements: NSSize = .init(width: 1, height: 1),
@ViewBuilder left: (() -> L),
@ViewBuilder right: (() -> R)
) {
@@ -93,25 +75,10 @@ struct SplitView<L: View, R: View>: View {
self._split = split
self.dividerColor = dividerColor
self.resizeIncrements = resizeIncrements
- self.resizePublisher = resizePublisher
self.left = left()
self.right = right()
}
- private func resize(for size: CGSize, amount: Double) {
- let dim: CGFloat
- switch (direction) {
- case .horizontal:
- dim = size.width
- case .vertical:
- dim = size.height
- }
-
- let pos = split * dim
- let new = min(max(minSize, pos + amount), dim - minSize)
- split = new / dim
- }
-
private func dragGesture(_ size: CGSize, splitterPoint: CGPoint) -> some Gesture {
return DragGesture()
.onChanged { gesture in
@@ -177,6 +144,35 @@ struct SplitView<L: View, R: View>: View {
return CGPoint(x: size.width / 2, y: leftRect.size.height)
}
}
+
+ // MARK: Accessibility
+
+ private var splitViewLabel: String {
+ switch direction {
+ case .horizontal:
+ return "Horizontal split view"
+ case .vertical:
+ return "Vertical split view"
+ }
+ }
+
+ private var leftPaneLabel: String {
+ switch direction {
+ case .horizontal:
+ return "Left pane"
+ case .vertical:
+ return "Top pane"
+ }
+ }
+
+ private var rightPaneLabel: String {
+ switch direction {
+ case .horizontal:
+ return "Right pane"
+ case .vertical:
+ return "Bottom pane"
+ }
+ }
}
enum SplitViewDirection: Codable {
diff --git a/macos/Sources/Features/Splits/TerminalSplitTreeView.swift b/macos/Sources/Features/Splits/TerminalSplitTreeView.swift
new file mode 100644
index 000000000..f19640707
--- /dev/null
+++ b/macos/Sources/Features/Splits/TerminalSplitTreeView.swift
@@ -0,0 +1,62 @@
+import SwiftUI
+
+struct TerminalSplitTreeView: View {
+ let tree: SplitTree<Ghostty.SurfaceView>
+ let onResize: (SplitTree<Ghostty.SurfaceView>.Node, Double) -> Void
+
+ var body: some View {
+ if let node = tree.zoomed ?? tree.root {
+ TerminalSplitSubtreeView(
+ node: node,
+ isRoot: node == tree.root,
+ onResize: onResize)
+ // This is necessary because we can't rely on SwiftUI's implicit
+ // structural identity to detect changes to this view. Due to
+ // the tree structure of splits it could result in bad beaviors.
+ // See: https://github.com/ghostty-org/ghostty/issues/7546
+ .id(node.structuralIdentity)
+ }
+ }
+}
+
+struct TerminalSplitSubtreeView: View {
+ @EnvironmentObject var ghostty: Ghostty.App
+
+ let node: SplitTree<Ghostty.SurfaceView>.Node
+ var isRoot: Bool = false
+ let onResize: (SplitTree<Ghostty.SurfaceView>.Node, Double) -> Void
+
+ var body: some View {
+ switch (node) {
+ case .leaf(let leafView):
+ Ghostty.InspectableSurface(
+ surfaceView: leafView,
+ isSplit: !isRoot)
+ .accessibilityElement(children: .contain)
+ .accessibilityLabel("Terminal pane")
+
+ case .split(let split):
+ let splitViewDirection: SplitViewDirection = switch (split.direction) {
+ case .horizontal: .horizontal
+ case .vertical: .vertical
+ }
+
+ SplitView(
+ splitViewDirection,
+ .init(get: {
+ CGFloat(split.ratio)
+ }, set: {
+ onResize(node, $0)
+ }),
+ dividerColor: ghostty.config.splitDividerColor,
+ resizeIncrements: .init(width: 1, height: 1),
+ left: {
+ TerminalSplitSubtreeView(node: split.left, onResize: onResize)
+ },
+ right: {
+ TerminalSplitSubtreeView(node: split.right, onResize: onResize)
+ }
+ )
+ }
+ }
+}
diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift
index 62384586a..c93a9450d 100644
--- a/macos/Sources/Features/Terminal/BaseTerminalController.swift
+++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift
@@ -41,8 +41,8 @@ class BaseTerminalController: NSWindowController,
didSet { syncFocusToSurfaceTree() }
}
- /// The surface tree for this window.
- @Published var surfaceTree: Ghostty.SplitNode? = nil {
+ /// The tree of splits within this terminal window.
+ @Published var surfaceTree: SplitTree<Ghostty.SurfaceView> = .init() {
didSet { surfaceTreeDidChange(from: oldValue, to: surfaceTree) }
}
@@ -75,6 +75,27 @@ class BaseTerminalController: NSWindowController,
/// The cancellables related to our focused surface.
private var focusedSurfaceCancellables: Set<AnyCancellable> = []
+ /// The time that undo/redo operations that contain running ptys are valid for.
+ var undoExpiration: Duration {
+ ghostty.config.undoTimeout
+ }
+
+ /// The undo manager for this controller is the undo manager of the window,
+ /// which we set via the delegate method.
+ override var undoManager: ExpiringUndoManager? {
+ // This should be set via the delegate method windowWillReturnUndoManager
+ if let result = window?.undoManager as? ExpiringUndoManager {
+ return result
+ }
+
+ // If the window one isn't set, we fallback to our global one.
+ if let appDelegate = NSApplication.shared.delegate as? AppDelegate {
+ return appDelegate.undoManager
+ }
+
+ return nil
+ }
+
struct SavedFrame {
let window: NSRect
let screen: NSRect
@@ -86,7 +107,7 @@ class BaseTerminalController: NSWindowController,
init(_ ghostty: Ghostty.App,
baseConfig base: Ghostty.SurfaceConfiguration? = nil,
- surfaceTree tree: Ghostty.SplitNode? = nil
+ surfaceTree tree: SplitTree<Ghostty.SurfaceView>? = nil
) {
self.ghostty = ghostty
self.derivedConfig = DerivedConfig(ghostty.config)
@@ -95,7 +116,7 @@ class BaseTerminalController: NSWindowController,
// Initialize our initial surface.
guard let ghostty_app = ghostty.app else { preconditionFailure("app must be loaded") }
- self.surfaceTree = tree ?? .leaf(.init(ghostty_app, baseConfig: base))
+ self.surfaceTree = tree ?? .init(view: Ghostty.SurfaceView(ghostty_app, baseConfig: base))
// Setup our notifications for behaviors
let center = NotificationCenter.default
@@ -125,6 +146,38 @@ class BaseTerminalController: NSWindowController,
name: .ghosttyMaximizeDidToggle,
object: nil)
+ // Splits
+ center.addObserver(
+ self,
+ selector: #selector(ghosttyDidCloseSurface(_:)),
+ name: Ghostty.Notification.ghosttyCloseSurface,
+ object: nil)
+ center.addObserver(
+ self,
+ selector: #selector(ghosttyDidNewSplit(_:)),
+ name: Ghostty.Notification.ghosttyNewSplit,
+ object: nil)
+ center.addObserver(
+ self,
+ selector: #selector(ghosttyDidEqualizeSplits(_:)),
+ name: Ghostty.Notification.didEqualizeSplits,
+ object: nil)
+ center.addObserver(
+ self,
+ selector: #selector(ghosttyDidFocusSplit(_:)),
+ name: Ghostty.Notification.ghosttyFocusSplit,
+ object: nil)
+ center.addObserver(
+ self,
+ selector: #selector(ghosttyDidToggleSplitZoom(_:)),
+ name: Ghostty.Notification.didToggleSplitZoom,
+ object: nil)
+ center.addObserver(
+ self,
+ selector: #selector(ghosttyDidResizeSplit(_:)),
+ name: Ghostty.Notification.didResizeSplit,
+ object: nil)
+
// Listen for local events that we need to know of outside of
// single surface handlers.
self.eventMonitor = NSEvent.addLocalMonitorForEvents(
@@ -134,20 +187,58 @@ class BaseTerminalController: NSWindowController,
deinit {
NotificationCenter.default.removeObserver(self)
-
+ undoManager?.removeAllActions(withTarget: self)
if let eventMonitor {
NSEvent.removeMonitor(eventMonitor)
}
}
+ // MARK: Methods
+
+ /// Create a new split.
+ @discardableResult
+ func newSplit(
+ at oldView: Ghostty.SurfaceView,
+ direction: SplitTree<Ghostty.SurfaceView>.NewDirection,
+ baseConfig config: Ghostty.SurfaceConfiguration? = nil
+ ) -> Ghostty.SurfaceView? {
+ // We can only create new splits for surfaces in our tree.
+ guard surfaceTree.root?.node(view: oldView) != nil else { return nil }
+
+ // Create a new surface view
+ guard let ghostty_app = ghostty.app else { return nil }
+ let newView = Ghostty.SurfaceView(ghostty_app, baseConfig: config)
+
+ // Do the split
+ let newTree: SplitTree<Ghostty.SurfaceView>
+ do {
+ newTree = try surfaceTree.insert(
+ view: newView,
+ at: oldView,
+ direction: direction)
+ } catch {
+ // If splitting fails for any reason (it should not), then we just log
+ // and return. The new view we created will be deinitialized and its
+ // no big deal.
+ Ghostty.logger.warning("failed to insert split: \(error)")
+ return nil
+ }
+
+ replaceSurfaceTree(
+ newTree,
+ moveFocusTo: newView,
+ moveFocusFrom: oldView,
+ undoAction: "New Split")
+
+ return newView
+ }
+
/// Called when the surfaceTree variable changed.
///
/// Subclasses should call super first.
- func surfaceTreeDidChange(from: Ghostty.SplitNode?, to: Ghostty.SplitNode?) {
- // If our surface tree becomes nil then ensure all surfaces
- // in the old tree have closed.
- if (to == nil) {
- from?.close()
+ func surfaceTreeDidChange(from: SplitTree<Ghostty.SurfaceView>, to: SplitTree<Ghostty.SurfaceView>) {
+ // If our surface tree becomes empty then we have no focused surface.
+ if (to.isEmpty) {
focusedSurface = nil
}
}
@@ -155,16 +246,14 @@ class BaseTerminalController: NSWindowController,
/// Update all surfaces with the focus state. This ensures that libghostty has an accurate view about
/// what surface is focused. This must be called whenever a surface OR window changes focus.
func syncFocusToSurfaceTree() {
- guard let tree = self.surfaceTree else { return }
-
- for leaf in tree {
+ for surfaceView in surfaceTree {
// Our focus state requires that this window is key and our currently
- // focused surface is the surface in this leaf.
+ // focused surface is the surface in this view.
let focused: Bool = (window?.isKeyWindow ?? false) &&
!commandPaletteIsShowing &&
focusedSurface != nil &&
- leaf.surface == focusedSurface!
- leaf.surface.focusDidChange(focused)
+ surfaceView == focusedSurface!
+ surfaceView.focusDidChange(focused)
}
}
@@ -177,6 +266,164 @@ class BaseTerminalController: NSWindowController,
savedFrame = .init(window: window.frame, screen: screen.visibleFrame)
}
+ func confirmClose(
+ messageText: String,
+ informativeText: String,
+ completion: @escaping () -> Void
+ ) {
+ // If we already have an alert, we need to wait for that one.
+ guard alert == nil else { return }
+
+ // If there is no window to attach the modal then we assume success
+ // since we'll never be able to show the modal.
+ guard let window else {
+ completion()
+ return
+ }
+
+ // If we need confirmation by any, show one confirmation for all windows
+ // in the tab group.
+ let alert = NSAlert()
+ alert.messageText = messageText
+ alert.informativeText = informativeText
+ alert.addButton(withTitle: "Close")
+ alert.addButton(withTitle: "Cancel")
+ alert.alertStyle = .warning
+ alert.beginSheetModal(for: window) { response in
+ self.alert = nil
+ if response == .alertFirstButtonReturn {
+ completion()
+ }
+ }
+
+ // Store our alert so we only ever show one.
+ self.alert = alert
+ }
+
+ /// Close a surface from a view.
+ func closeSurface(
+ _ view: Ghostty.SurfaceView,
+ withConfirmation: Bool = true
+ ) {
+ guard let node = surfaceTree.root?.node(view: view) else { return }
+ closeSurface(node, withConfirmation: withConfirmation)
+ }
+
+ /// Close a surface node (which may contain splits), requesting confirmation if necessary.
+ ///
+ /// This will also insert the proper undo stack information in.
+ func closeSurface(
+ _ node: SplitTree<Ghostty.SurfaceView>.Node,
+ withConfirmation: Bool = true
+ ) {
+ // This node must be part of our tree
+ guard surfaceTree.contains(node) else { return }
+
+ // If the child process is not alive, then we exit immediately
+ guard withConfirmation else {
+ removeSurfaceNode(node)
+ return
+ }
+
+ // Confirm close. We use an NSAlert instead of a SwiftUI confirmationDialog
+ // due to SwiftUI bugs (see Ghostty #560). To repeat from #560, the bug is that
+ // confirmationDialog allows the user to Cmd-W close the alert, but when doing
+ // so SwiftUI does not update any of the bindings to note that window is no longer
+ // being shown, and provides no callback to detect this.
+ confirmClose(
+ messageText: "Close Terminal?",
+ informativeText: "The terminal still has a running process. If you close the terminal the process will be killed."
+ ) { [weak self] in
+ if let self {
+ self.removeSurfaceNode(node)
+ }
+ }
+ }
+
+ // MARK: Split Tree Management
+
+ /// Find the next surface to focus when a node is being closed.
+ /// Goes to previous split unless we're the leftmost leaf, then goes to next.
+ private func findNextFocusTargetAfterClosing(node: SplitTree<Ghostty.SurfaceView>.Node) -> Ghostty.SurfaceView? {
+ guard let root = surfaceTree.root else { return nil }
+
+ // If we're the leftmost, then we move to the next surface after closing.
+ // Otherwise, we move to the previous.
+ if root.leftmostLeaf() == node.leftmostLeaf() {
+ return surfaceTree.focusTarget(for: .next, from: node)
+ } else {
+ return surfaceTree.focusTarget(for: .previous, from: node)
+ }
+ }
+
+ /// Remove a node from the surface tree and move focus appropriately.
+ ///
+ /// This also updates the undo manager to support restoring this node.
+ ///
+ /// This does no confirmation and assumes confirmation is already done.
+ private func removeSurfaceNode(_ node: SplitTree<Ghostty.SurfaceView>.Node) {
+ // Move focus if the closed surface was focused and we have a next target
+ let nextFocus: Ghostty.SurfaceView? = if node.contains(
+ where: { $0 == focusedSurface }
+ ) {
+ findNextFocusTargetAfterClosing(node: node)
+ } else {
+ nil
+ }
+
+ replaceSurfaceTree(
+ surfaceTree.remove(node),
+ moveFocusTo: nextFocus,
+ moveFocusFrom: focusedSurface,
+ undoAction: "Close Terminal"
+ )
+ }
+
+ private func replaceSurfaceTree(
+ _ newTree: SplitTree<Ghostty.SurfaceView>,
+ moveFocusTo newView: Ghostty.SurfaceView? = nil,
+ moveFocusFrom oldView: Ghostty.SurfaceView? = nil,
+ undoAction: String? = nil
+ ) {
+ // Setup our new split tree
+ let oldTree = surfaceTree
+ surfaceTree = newTree
+ if let newView {
+ DispatchQueue.main.async {
+ Ghostty.moveFocus(to: newView, from: oldView)
+ }
+ }
+
+ // Setup our undo
+ if let undoManager {
+ if let undoAction {
+ undoManager.setActionName(undoAction)
+ }
+ undoManager.registerUndo(
+ withTarget: self,
+ expiresAfter: undoExpiration
+ ) { target in
+ target.surfaceTree = oldTree
+ if let oldView {
+ DispatchQueue.main.async {
+ Ghostty.moveFocus(to: oldView, from: target.focusedSurface)
+ }
+ }
+
+ undoManager.registerUndo(
+ withTarget: target,
+ expiresAfter: target.undoExpiration
+ ) { target in
+ target.replaceSurfaceTree(
+ newTree,
+ moveFocusTo: newView,
+ moveFocusFrom: target.focusedSurface,
+ undoAction: undoAction)
+ }
+ }
+ }
+ }
+
// MARK: Notifications
@objc private func didChangeScreenParametersNotification(_ notification: Notification) {
@@ -239,17 +486,158 @@ class BaseTerminalController: NSWindowController,
@objc private func ghosttyCommandPaletteDidToggle(_ notification: Notification) {
guard let surfaceView = notification.object as? Ghostty.SurfaceView else { return }
- guard surfaceTree?.contains(view: surfaceView) ?? false else { return }
+ guard surfaceTree.contains(surfaceView) else { return }
toggleCommandPalette(nil)
}
@objc private func ghosttyMaximizeDidToggle(_ notification: Notification) {
guard let window else { return }
guard let surfaceView = notification.object as? Ghostty.SurfaceView else { return }
- guard surfaceTree?.contains(view: surfaceView) ?? false else { return }
+ guard surfaceTree.contains(surfaceView) else { return }
window.zoom(nil)
}
+ @objc private func ghosttyDidCloseSurface(_ notification: Notification) {
+ guard let target = notification.object as? Ghostty.SurfaceView else { return }
+ guard let node = surfaceTree.root?.node(view: target) else { return }
+ closeSurface(
+ node,
+ withConfirmation: (notification.userInfo?["process_alive"] as? Bool) ?? false)
+ }
+
+ @objc private func ghosttyDidNewSplit(_ notification: Notification) {
+ // The target must be within our tree
+ guard let oldView = notification.object as? Ghostty.SurfaceView else { return }
+ guard surfaceTree.root?.node(view: oldView) != nil else { return }
+
+ // Notification must contain our base config
+ let configAny = notification.userInfo?[Ghostty.Notification.NewSurfaceConfigKey]
+ let config = configAny as? Ghostty.SurfaceConfiguration
+
+ // Determine our desired direction
+ guard let directionAny = notification.userInfo?["direction"] else { return }
+ guard let direction = directionAny as? ghostty_action_split_direction_e else { return }
+ let splitDirection: SplitTree<Ghostty.SurfaceView>.NewDirection
+ switch (direction) {
+ case GHOSTTY_SPLIT_DIRECTION_RIGHT: splitDirection = .right
+ case GHOSTTY_SPLIT_DIRECTION_LEFT: splitDirection = .left
+ case GHOSTTY_SPLIT_DIRECTION_DOWN: splitDirection = .down
+ case GHOSTTY_SPLIT_DIRECTION_UP: splitDirection = .up
+ default: return
+ }
+
+ newSplit(at: oldView, direction: splitDirection, baseConfig: config)
+ }
+
+ @objc private func ghosttyDidEqualizeSplits(_ notification: Notification) {
+ guard let target = notification.object as? Ghostty.SurfaceView else { return }
+
+ // Check if target surface is in current controller's tree
+ guard surfaceTree.contains(target) else { return }
+
+ // Equalize the splits
+ surfaceTree = surfaceTree.equalize()
+ }
+
+ @objc private func ghosttyDidFocusSplit(_ notification: Notification) {
+ // The target must be within our tree
+ guard let target = notification.object as? Ghostty.SurfaceView else { return }
+ guard surfaceTree.root?.node(view: target) != nil else { return }
+
+ // Get the direction from the notification
+ guard let directionAny = notification.userInfo?[Ghostty.Notification.SplitDirectionKey] else { return }
+ guard let direction = directionAny as? Ghostty.SplitFocusDirection else { return }
+
+ // Convert Ghostty.SplitFocusDirection to our SplitTree.FocusDirection
+ let focusDirection: SplitTree<Ghostty.SurfaceView>.FocusDirection
+ switch direction {
+ case .previous: focusDirection = .previous
+ case .next: focusDirection = .next
+ case .up: focusDirection = .spatial(.up)
+ case .down: focusDirection = .spatial(.down)
+ case .left: focusDirection = .spatial(.left)
+ case .right: focusDirection = .spatial(.right)
+ }
+
+ // Find the node for the target surface
+ guard let targetNode = surfaceTree.root?.node(view: target) else { return }
+
+ // Find the next surface to focus
+ guard let nextSurface = surfaceTree.focusTarget(for: focusDirection, from: targetNode) else {
+ return
+ }
+
+ // Remove the zoomed state for this surface tree.
+ if surfaceTree.zoomed != nil {
+ surfaceTree = .init(root: surfaceTree.root, zoomed: nil)
+ }
+
+ // Move focus to the next surface
+ DispatchQueue.main.async {
+ Ghostty.moveFocus(to: nextSurface, from: target)
+ }
+ }
+
+ @objc private func ghosttyDidToggleSplitZoom(_ notification: Notification) {
+ // The target must be within our tree
+ guard let target = notification.object as? Ghostty.SurfaceView else { return }
+ guard let targetNode = surfaceTree.root?.node(view: target) else { return }
+
+ // Toggle the zoomed state
+ if surfaceTree.zoomed == targetNode {
+ // Already zoomed, unzoom it
+ surfaceTree = SplitTree(root: surfaceTree.root, zoomed: nil)
+ } else {
+ // We require that the split tree have splits
+ guard surfaceTree.isSplit else { return }
+
+ // Not zoomed or different node zoomed, zoom this node
+ surfaceTree = SplitTree(root: surfaceTree.root, zoomed: targetNode)
+ }
+
+ // Move focus to our window. Importantly this ensures that if we click the
+ // reset zoom button in a tab bar of an unfocused tab that we become focused.
+ window?.makeKeyAndOrderFront(nil)
+
+ // Ensure focus stays on the target surface. We lose focus when we do
+ // this so we need to grab it again.
+ DispatchQueue.main.async {
+ Ghostty.moveFocus(to: target)
+ }
+ }
+
+ @objc private func ghosttyDidResizeSplit(_ notification: Notification) {
+ // The target must be within our tree
+ guard let target = notification.object as? Ghostty.SurfaceView else { return }
+ guard let targetNode = surfaceTree.root?.node(view: target) else { return }
+
+ // Extract direction and amount from notification
+ guard let directionAny = notification.userInfo?[Ghostty.Notification.ResizeSplitDirectionKey] else { return }
+ guard let direction = directionAny as? Ghostty.SplitResizeDirection else { return }
+
+ guard let amountAny = notification.userInfo?[Ghostty.Notification.ResizeSplitAmountKey] else { return }
+ guard let amount = amountAny as? UInt16 else { return }
+
+ // Convert Ghostty.SplitResizeDirection to SplitTree.Spatial.Direction
+ let spatialDirection: SplitTree<Ghostty.SurfaceView>.Spatial.Direction
+ switch direction {
+ case .up: spatialDirection = .up
+ case .down: spatialDirection = .down
+ case .left: spatialDirection = .left
+ case .right: spatialDirection = .right
+ }
+
+ // Use viewBounds for the spatial calculation bounds
+ let bounds = CGRect(origin: .zero, size: surfaceTree.viewBounds())
+
+ // Perform the resize using the new SplitTree resize method
+ do {
+ surfaceTree = try surfaceTree.resize(node: targetNode, by: amount, in: spatialDirection, with: bounds)
+ } catch {
+ Ghostty.logger.warning("failed to resize split: \(error)")
+ }
+ }
+
// MARK: Local Events
private func localEventHandler(_ event: NSEvent) -> NSEvent? {
@@ -263,20 +651,17 @@ class BaseTerminalController: NSWindowController,
}
private func localEventFlagsChanged(_ event: NSEvent) -> NSEvent? {
- // Go through all our surfaces and notify it that the flags changed.
- if let surfaceTree {
- var surfaces: [Ghostty.SurfaceView] = surfaceTree.map { $0.surface }
-
- // If we're the main window receiving key input, then we want to avoid
- // calling this on our focused surface because that'll trigger a double
- // flagsChanged call.
- if NSApp.mainWindow == window {
- surfaces = surfaces.filter { $0 != focusedSurface }
- }
+ var surfaces: [Ghostty.SurfaceView] = surfaceTree.map { $0 }
- for surface in surfaces {
- surface.flagsChanged(with: event)
- }
+ // If we're the main window receiving key input, then we want to avoid
+ // calling this on our focused surface because that'll trigger a double
+ // flagsChanged call.
+ if NSApp.mainWindow == window {
+ surfaces = surfaces.filter { $0 != focusedSurface }
+ }
+
+ for surface in surfaces {
+ surface.flagsChanged(with: event)
}
return event
@@ -284,11 +669,6 @@ class BaseTerminalController: NSWindowController,
// MARK: TerminalViewDelegate
- // Note: this is different from surfaceDidTreeChange(from:,to:) because this is called
- // when the currently set value changed in place and the from:to: variant is called
- // when the variable was set.
- func surfaceTreeDidChange() {}
-
func focusedSurfaceDidChange(to: Ghostty.SurfaceView?) {
let lastFocusedSurface = focusedSurface
focusedSurface = to
@@ -301,7 +681,7 @@ class BaseTerminalController: NSWindowController,
// want to care if the surface is in the tree so we don't listen to titles of
// closed surfaces.
if let titleSurface = focusedSurface ?? lastFocusedSurface,
- surfaceTree?.contains(view: titleSurface) ?? false {
+ surfaceTree.contains(titleSurface) {
// If we have a surface, we want to listen for title changes.
titleSurface.$title
.sink { [weak self] in self?.titleDidChange(to: $0) }
@@ -336,7 +716,15 @@ class BaseTerminalController: NSWindowController,
self.window?.contentResizeIncrements = to
}
- func zoomStateDidChange(to: Bool) {}
+ func splitDidResize(node: SplitTree<Ghostty.SurfaceView>.Node, to newRatio: Double) {
+ let resizedNode = node.resize(to: newRatio)
+ do {
+ surfaceTree = try surfaceTree.replace(node: node, with: resizedNode)
+ } catch {
+ Ghostty.logger.warning("failed to replace node during split resize: \(error)")
+ return
+ }
+ }
func performAction(_ action: String, on surfaceView: Ghostty.SurfaceView) {
guard let surface = surfaceView.surface else { return }
@@ -396,6 +784,8 @@ class BaseTerminalController: NSWindowController,
}
}
+ func fullscreenDidChange() {}
+
// MARK: Clipboard Confirmation
@objc private func onConfirmClipboardRequest(notification: SwiftUI.Notification) {
@@ -462,6 +852,11 @@ class BaseTerminalController: NSWindowController,
// MARK: NSWindowController
override func windowDidLoad() {
+ super.windowDidLoad()
+
+ // Setup our undo manager.
+
+ // Everything beyond here is setting up the window
guard let window else { return }
// If there is a hardcoded title in the configuration, we set that
@@ -491,35 +886,21 @@ class BaseTerminalController: NSWindowController,
guard let window = self.window else { return true }
// If we have no surfaces, close.
- guard let node = self.surfaceTree else { return true }
+ if surfaceTree.isEmpty { return true }
// If we already have an alert, continue with it
guard alert == nil else { return false }
// If our surfaces don't require confirmation, close.
- if (!node.needsConfirmQuit()) { return true }
+ if !surfaceTree.contains(where: { $0.needsConfirmQuit }) { return true }
// We require confirmation, so show an alert as long as we aren't already.
- let alert = NSAlert()
- alert.messageText = "Close Terminal?"
- alert.informativeText = "The terminal still has a running process. If you close the " +
- "terminal the process will be killed."
- alert.addButton(withTitle: "Close the Terminal")
- alert.addButton(withTitle: "Cancel")
- alert.alertStyle = .warning
- alert.beginSheetModal(for: window, completionHandler: { response in
- self.alert = nil
- switch (response) {
- case .alertFirstButtonReturn:
- alert.window.orderOut(nil)
- window.close()
-
- default:
- break
- }
- })
-
- self.alert = alert
+ confirmClose(
+ messageText: "Close Terminal?",
+ informativeText: "The terminal still has a running process. If you close the terminal the process will be killed."
+ ) {
+ window.close()
+ }
return false
}
@@ -531,6 +912,9 @@ class BaseTerminalController: NSWindowController,
// the view and the window so we had to nil this out to break it but I think this
// may now be resolved. We should verify that no memory leaks and we can remove this.
window.contentView = nil
+
+ // Make sure we clean up all our undos
+ window.undoManager?.removeAllActions(withTarget: self)
}
func windowDidBecomeKey(_ notification: Notification) {
@@ -546,10 +930,9 @@ class BaseTerminalController: NSWindowController,
}
func windowDidChangeOcclusionState(_ notification: Notification) {
- guard let surfaceTree = self.surfaceTree else { return }
let visible = self.window?.occlusionState.contains(.visible) ?? false
- for leaf in surfaceTree {
- if let surface = leaf.surface.surface {
+ for view in surfaceTree {
+ if let surface = view.surface {
ghostty_surface_set_occlusion(surface, visible)
}
}
@@ -563,6 +946,11 @@ class BaseTerminalController: NSWindowController,
windowFrameDidChange()
}
+ func windowWillReturnUndoManager(_ window: NSWindow) -> UndoManager? {
+ guard let appDelegate = NSApplication.shared.delegate as? AppDelegate else { return nil }
+ return appDelegate.undoManager
+ }
+
// MARK: First Responder
@IBAction func close(_ sender: Any) {
diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift
index cf2dd3348..c5e1c413f 100644
--- a/macos/Sources/Features/Terminal/TerminalController.swift
+++ b/macos/Sources/Features/Terminal/TerminalController.swift
@@ -5,8 +5,34 @@ import Combine
import GhosttyKit
/// A classic, tabbed terminal experience.
-class TerminalController: BaseTerminalController {
- override var windowNibName: NSNib.Name? { "Terminal" }
+class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Controller {
+ override var windowNibName: NSNib.Name? {
+ let defaultValue = "Terminal"
+
+ guard let appDelegate = NSApp.delegate as? AppDelegate else { return defaultValue }
+ let config = appDelegate.ghostty.config
+
+ // If we have no window decorations, there's no reason to do anything but
+ // the default titlebar (because there will be no titlebar).
+ if !config.windowDecorations {
+ return defaultValue
+ }
+
+ let nib = switch config.macosTitlebarStyle {
+ case "native": "Terminal"
+ case "hidden": "TerminalHiddenTitlebar"
+ case "transparent": "TerminalTransparentTitlebar"
+ case "tabs":
+ if #available(macOS 26.0, *) {
+ "TerminalTabsTitlebarTahoe"
+ } else {
+ "TerminalTabsTitlebarVentura"
+ }
+ default: defaultValue
+ }
+
+ return nib
+ }
/// This is set to true when we care about frame changes. This is a small optimization since
/// this controller registers a listener for ALL frame change notifications and this lets us bail
@@ -32,7 +58,8 @@ class TerminalController: BaseTerminalController {
init(_ ghostty: Ghostty.App,
withBaseConfig base: Ghostty.SurfaceConfiguration? = nil,
- withSurfaceTree tree: Ghostty.SplitNode? = nil
+ withSurfaceTree tree: SplitTree<Ghostty.SurfaceView>? = nil,
+ parent: NSWindow? = nil
) {
// The window we manage is not restorable if we've specified a command
// to execute. We do this because the restored window is meaningless at the
@@ -87,12 +114,6 @@ class TerminalController: BaseTerminalController {
object: nil)
center.addObserver(
self,
- selector: #selector(onEqualizeSplits),
- name: Ghostty.Notification.didEqualizeSplits,
- object: nil
- )
- center.addObserver(
- self,
selector: #selector(onCloseWindow),
name: .ghosttyCloseWindow,
object: nil
@@ -111,29 +132,244 @@ class TerminalController: BaseTerminalController {
// MARK: Base Controller Overrides
- override func surfaceTreeDidChange(from: Ghostty.SplitNode?, to: Ghostty.SplitNode?) {
+ override func surfaceTreeDidChange(from: SplitTree<Ghostty.SurfaceView>, to: SplitTree<Ghostty.SurfaceView>) {
super.surfaceTreeDidChange(from: from, to: to)
+
+ // Whenever our surface tree changes in any way (new split, close split, etc.)
+ // we want to invalidate our state.
+ invalidateRestorableState()
+
+ // Update our zoom state
+ if let window = window as? TerminalWindow {
+ window.surfaceIsZoomed = to.zoomed != nil
+ }
// If our surface tree is now nil then we close our window.
- if (to == nil) {
+ if (to.isEmpty) {
self.window?.close()
}
}
- func fullscreenDidChange() {
+ override func fullscreenDidChange() {
+ super.fullscreenDidChange()
+
// When our fullscreen state changes, we resync our appearance because some
// properties change when fullscreen or not.
guard let focusedSurface else { return }
- if (!(fullscreenStyle?.isFullscreen ?? false) &&
- ghostty.config.macosTitlebarStyle == "hidden")
- {
- applyHiddenTitlebarStyle()
- }
syncAppearance(focusedSurface.derivedConfig)
}
+ // MARK: Terminal Creation
+
+ /// Returns all the available terminal controllers present in the app currently.
+ static var all: [TerminalController] {
+ return NSApplication.shared.windows.compactMap {
+ $0.windowController as? TerminalController
+ }
+ }
+
+ // Keep track of the last point that our window was launched at so that new
+ // windows "cascade" over each other and don't just launch directly on top
+ // of each other.
+ private static var lastCascadePoint = NSPoint(x: 0, y: 0)
+
+ // The preferred parent terminal controller.
+ static var preferredParent: TerminalController? {
+ all.first {
+ $0.window?.isMainWindow ?? false
+ } ?? all.last
+ }
+
+ /// The "new window" action.
+ static func newWindow(
+ _ ghostty: Ghostty.App,
+ withBaseConfig baseConfig: Ghostty.SurfaceConfiguration? = nil,
+ withParent explicitParent: NSWindow? = nil
+ ) -> TerminalController {
+ let c = TerminalController.init(ghostty, withBaseConfig: baseConfig)
+
+ // Get our parent. Our parent is the one explicitly given to us,
+ // otherwise the focused terminal, otherwise an arbitrary one.
+ let parent: NSWindow? = explicitParent ?? preferredParent?.window
+
+ if let parent {
+ if parent.styleMask.contains(.fullScreen) {
+ parent.toggleFullScreen(nil)
+ } else if ghostty.config.windowFullscreen {
+ switch (ghostty.config.windowFullscreenMode) {
+ case .native:
+ // Native has to be done immediately so that our stylemask contains
+ // fullscreen for the logic later in this method.
+ c.toggleFullscreen(mode: .native)
+
+ case .nonNative, .nonNativeVisibleMenu, .nonNativePaddedNotch:
+ // If we're non-native then we have to do it on a later loop
+ // so that the content view is setup.
+ DispatchQueue.main.async {
+ c.toggleFullscreen(mode: ghostty.config.windowFullscreenMode)
+ }
+ }
+ }
+ }
+
+ // We're dispatching this async because otherwise the lastCascadePoint doesn't
+ // take effect. Our best theory is there is some next-event-loop-tick logic
+ // that Cocoa is doing that we need to be after.
+ DispatchQueue.main.async {
+ // Only cascade if we aren't fullscreen.
+ if let window = c.window {
+ if (!window.styleMask.contains(.fullScreen)) {
+ Self.lastCascadePoint = window.cascadeTopLeft(from: Self.lastCascadePoint)
+ }
+ }
+
+ c.showWindow(self)
+ }
+
+ // Setup our undo
+ if let undoManager = c.undoManager {
+ undoManager.setActionName("New Window")
+ undoManager.registerUndo(
+ withTarget: c,
+ expiresAfter: c.undoExpiration
+ ) { target in
+ // Close the window when undoing
+ undoManager.disableUndoRegistration {
+ target.closeWindow(nil)
+ }
+
+ // Register redo action
+ undoManager.registerUndo(
+ withTarget: ghostty,
+ expiresAfter: target.undoExpiration
+ ) { ghostty in
+ _ = TerminalController.newWindow(
+ ghostty,
+ withBaseConfig: baseConfig,
+ withParent: explicitParent)
+ }
+ }
+ }
+
+ return c
+ }
+
+ static func newTab(
+ _ ghostty: Ghostty.App,
+ from parent: NSWindow? = nil,
+ withBaseConfig baseConfig: Ghostty.SurfaceConfiguration? = nil
+ ) -> TerminalController? {
+ // Making sure that we're dealing with a TerminalController. If not,
+ // then we just create a new window.
+ guard let parent,
+ let parentController = parent.windowController as? TerminalController else {
+ return newWindow(ghostty, withBaseConfig: baseConfig, withParent: parent)
+ }
+
+ // If our parent is in non-native fullscreen, then new tabs do not work.
+ // See: https://github.com/mitchellh/ghostty/issues/392
+ if let fullscreenStyle = parentController.fullscreenStyle,
+ fullscreenStyle.isFullscreen && !fullscreenStyle.supportsTabs {
+ let alert = NSAlert()
+ alert.messageText = "Cannot Create New Tab"
+ alert.informativeText = "New tabs are unsupported while in non-native fullscreen. Exit fullscreen and try again."
+ alert.addButton(withTitle: "OK")
+ alert.alertStyle = .warning
+ alert.beginSheetModal(for: parent)
+ return nil
+ }
+
+ // Create a new window and add it to the parent
+ let controller = TerminalController.init(ghostty, withBaseConfig: baseConfig)
+ guard let window = controller.window else { return controller }
+
+ // If the parent is miniaturized, then macOS exhibits really strange behaviors
+ // so we have to bring it back out.
+ if (parent.isMiniaturized) { parent.deminiaturize(self) }
+
+ // If our parent tab group already has this window, macOS added it and
+ // we need to remove it so we can set the correct order in the next line.
+ // If we don't do this, macOS gets really confused and the tabbedWindows
+ // state becomes incorrect.
+ //
+ // At the time of writing this code, the only known case this happens
+ // is when the "+" button is clicked in the tab bar.
+ if let tg = parent.tabGroup,
+ tg.windows.firstIndex(of: window) != nil {
+ tg.removeWindow(window)
+ }
+
+ // If we don't allow tabs then we create a new window instead.
+ if (window.tabbingMode != .disallowed) {
+ // Add the window to the tab group and show it.
+ switch ghostty.config.windowNewTabPosition {
+ case "end":
+ // If we already have a tab group and we want the new tab to open at the end,
+ // then we use the last window in the tab group as the parent.
+ if let last = parent.tabGroup?.windows.last {
+ last.addTabbedWindow(window, ordered: .above)
+ } else {
+ fallthrough
+ }
+
+ case "current": fallthrough
+ default:
+ parent.addTabbedWindow(window, ordered: .above)
+ }
+ }
+
+ // We're dispatching this async because otherwise the lastCascadePoint doesn't
+ // take effect. Our best theory is there is some next-event-loop-tick logic
+ // that Cocoa is doing that we need to be after.
+ DispatchQueue.main.async {
+ // Only cascade if we aren't fullscreen and are alone in the tab group.
+ if !window.styleMask.contains(.fullScreen) &&
+ window.tabGroup?.windows.count ?? 1 == 1 {
+ Self.lastCascadePoint = window.cascadeTopLeft(from: Self.lastCascadePoint)
+ }
+
+ controller.showWindow(self)
+ window.makeKeyAndOrderFront(self)
+ }
+
+ // It takes an event loop cycle until the macOS tabGroup state becomes
+ // consistent which causes our tab labeling to be off when the "+" button
+ // is used in the tab bar. This fixes that. If we can find a more robust
+ // solution we should do that.
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
+ controller.relabelTabs()
+ }
+
+ // Setup our undo
+ if let undoManager = parentController.undoManager {
+ undoManager.setActionName("New Tab")
+ undoManager.registerUndo(
+ withTarget: controller,
+ expiresAfter: controller.undoExpiration
+ ) { target in
+ // Close the tab when undoing
+ undoManager.disableUndoRegistration {
+ target.closeTab(nil)
+ }
+
+ // Register redo action
+ undoManager.registerUndo(
+ withTarget: ghostty,
+ expiresAfter: target.undoExpiration
+ ) { ghostty in
+ _ = TerminalController.newTab(
+ ghostty,
+ from: parent,
+ withBaseConfig: baseConfig)
+ }
+ }
+ }
+
+ return controller
+ }
+
//MARK: - Methods
@objc private func ghosttyConfigDidChange(_ notification: Notification) {
@@ -149,8 +385,8 @@ class TerminalController: BaseTerminalController {
// If we have no surfaces in our window (is that possible?) then we update
// our window appearance based on the root config. If we have surfaces, we
- // don't call this because the TODO
- if surfaceTree == nil {
+ // don't call this because focused surface changes will trigger appearance updates.
+ if surfaceTree.isEmpty {
syncAppearance(.init(config))
}
@@ -160,7 +396,7 @@ class TerminalController: BaseTerminalController {
// This is a surface-level config update. If we have the surface, we
// update our appearance based on it.
guard let surfaceView = notification.object as? Ghostty.SurfaceView else { return }
- guard surfaceTree?.contains(view: surfaceView) ?? false else { return }
+ guard surfaceTree.contains(surfaceView) else { return }
// We can't use surfaceView.derivedConfig because it may not be updated
// yet since it also responds to notifications.
@@ -172,28 +408,25 @@ class TerminalController: BaseTerminalController {
/// changes, when a window is closed, and when tabs are reordered
/// with the mouse.
func relabelTabs() {
- // Reset this to false. It'll be set back to true later.
- tabListenForFrame = false
-
- guard let windows = self.window?.tabbedWindows as? [TerminalWindow] else { return }
-
// We only listen for frame changes if we have more than 1 window,
// otherwise the accessory view doesn't matter.
- tabListenForFrame = windows.count > 1
-
- for (tab, window) in zip(1..., windows) {
- // We need to clear any windows beyond this because they have had
- // a keyEquivalent set previously.
- guard tab <= 9 else {
- window.keyEquivalent = ""
- continue
- }
+ tabListenForFrame = window?.tabbedWindows?.count ?? 0 > 1
+
+ if let windows = window?.tabbedWindows as? [TerminalWindow] {
+ for (tab, window) in zip(1..., windows) {
+ // We need to clear any windows beyond this because they have had
+ // a keyEquivalent set previously.
+ guard tab <= 9 else {
+ window.keyEquivalent = ""
+ continue
+ }
- let action = "goto_tab:\(tab)"
- if let equiv = ghostty.config.keyboardShortcut(for: action) {
- window.keyEquivalent = "\(equiv)"
- } else {
- window.keyEquivalent = ""
+ let action = "goto_tab:\(tab)"
+ if let equiv = ghostty.config.keyboardShortcut(for: action) {
+ window.keyEquivalent = "\(equiv)"
+ } else {
+ window.keyEquivalent = ""
+ }
}
}
}
@@ -226,18 +459,11 @@ class TerminalController: BaseTerminalController {
}
private func syncAppearance(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) {
- guard let window = self.window as? TerminalWindow else { return }
-
- // Set our explicit appearance if we need to based on the configuration.
- window.appearance = surfaceConfig.windowAppearance
-
- // Update our window light/darkness based on our updated background color
- window.isLightTheme = OSColor(surfaceConfig.backgroundColor).isLightColor
+ // Let our window handle its own appearance
+ guard let window = window as? TerminalWindow else { return }
- // If our window is not visible, then we do nothing. Some things such as blurring
- // have no effect if the window is not visible. Ultimately, we'll have this called
- // at some point when a surface becomes focused.
- guard window.isVisible else { return }
+ // Sync our zoom state for splits
+ window.surfaceIsZoomed = surfaceTree.zoomed != nil
// Set the font for the window and tab titles.
if let titleFontName = surfaceConfig.windowTitleFontFamily {
@@ -246,85 +472,8 @@ class TerminalController: BaseTerminalController {
window.titlebarFont = nil
}
- // If we have window transparency then set it transparent. Otherwise set it opaque.
-
- // Window transparency only takes effect if our window is not native fullscreen.
- // In native fullscreen we disable transparency/opacity because the background
- // becomes gray and widgets show through.
- if (!window.styleMask.contains(.fullScreen) &&
- surfaceConfig.backgroundOpacity < 1
- ) {
- window.isOpaque = false
-
- // This is weird, but we don't use ".clear" because this creates a look that
- // matches Terminal.app much more closer. This lets users transition from
- // Terminal.app more easily.
- window.backgroundColor = .white.withAlphaComponent(0.001)
-
- ghostty_set_window_background_blur(ghostty.app, Unmanaged.passUnretained(window).toOpaque())
- } else {
- window.isOpaque = true
- window.backgroundColor = .windowBackgroundColor
- }
-
- window.hasShadow = surfaceConfig.macosWindowShadow
-
- guard window.hasStyledTabs else { return }
-
- // Our background color depends on if our focused surface borders the top or not.
- // If it does, we match the focused surface. If it doesn't, we use the app
- // configuration.
- let backgroundColor: OSColor
- if let surfaceTree {
- if let focusedSurface, surfaceTree.doesBorderTop(view: focusedSurface) {
- // Similar to above, an alpha component of "0" causes compositor issues, so
- // we use 0.001. See: https://github.com/ghostty-org/ghostty/pull/4308
- backgroundColor = OSColor(focusedSurface.backgroundColor ?? surfaceConfig.backgroundColor).withAlphaComponent(0.001)
- } else {
- // We don't have a focused surface or our surface doesn't border the
- // top. We choose to match the color of the top-left most surface.
- backgroundColor = OSColor(surfaceTree.topLeft().backgroundColor ?? derivedConfig.backgroundColor)
- }
- } else {
- backgroundColor = OSColor(self.derivedConfig.backgroundColor)
- }
- window.titlebarColor = backgroundColor.withAlphaComponent(surfaceConfig.backgroundOpacity)
-
- if (window.isOpaque) {
- // Bg color is only synced if we have no transparency. This is because
- // the transparency is handled at the surface level (window.backgroundColor
- // ignores alpha components)
- window.backgroundColor = backgroundColor
-
- // If there is transparency, calling this will make the titlebar opaque
- // so we only call this if we are opaque.
- window.updateTabBar()
- }
- }
-
- private func setInitialWindowPosition(x: Int16?, y: Int16?, windowDecorations: Bool) {
- guard let window else { return }
-
- // If we don't have an X/Y then we try to use the previously saved window pos.
- guard let x, let y else {
- if (!LastWindowPosition.shared.restore(window)) {
- window.center()
- }
-
- return
- }
-
- // Prefer the screen our window is being placed on otherwise our primary screen.
- guard let screen = window.screen ?? NSScreen.screens.first else {
- window.center()
- return
- }
-
- // Orient based on the top left of the primary monitor
- let frame = screen.visibleFrame
- window.setFrameOrigin(.init(
- x: frame.minX + CGFloat(x),
- y: frame.maxY - (CGFloat(y) + window.frame.height)))
+ // Call this last in case it uses any of the properties above.
+ window.syncAppearance(surfaceConfig)
}
/// Returns the default size of the window. This is contextual based on the focused surface because
@@ -376,53 +525,301 @@ class TerminalController: BaseTerminalController {
return frame
}
- //MARK: - NSWindowController
+ /// This is called anytime a node in the surface tree is being removed.
+ override func closeSurface(
+ _ node: SplitTree<Ghostty.SurfaceView>.Node,
+ withConfirmation: Bool = true
+ ) {
+ // If this isn't the root then we're dealing with a split closure.
+ if surfaceTree.root != node {
+ super.closeSurface(node, withConfirmation: withConfirmation)
+ return
+ }
- override func windowWillLoad() {
- // We do NOT want to cascade because we handle this manually from the manager.
- shouldCascadeWindows = false
+ // More than 1 window means we have tabs and we're closing a tab
+ if window?.tabGroup?.windows.count ?? 0 > 1 {
+ closeTab(nil)
+ return
+ }
+
+ // 1 window, closing the window
+ closeWindow(nil)
+ }
+
+ private func closeTabImmediately() {
+ guard let window = window else { return }
+ guard let tabGroup = window.tabGroup,
+ tabGroup.windows.count > 1 else {
+ closeWindowImmediately()
+ return
+ }
+
+ // Undo
+ if let undoManager, let undoState {
+ // Register undo action to restore the tab
+ undoManager.setActionName("Close Tab")
+ undoManager.registerUndo(
+ withTarget: ghostty,
+ expiresAfter: undoExpiration
+ ) { ghostty in
+ let newController = TerminalController(ghostty, with: undoState)
+
+ // Register redo action
+ undoManager.registerUndo(
+ withTarget: newController,
+ expiresAfter: newController.undoExpiration
+ ) { target in
+ target.closeTabImmediately()
+ }
+ }
+ }
+
+ window.close()
}
- fileprivate func applyHiddenTitlebarStyle() {
+ /// Closes the current window (including any other tabs) immediately and without
+ /// confirmation. This will setup proper undo state so the action can be undone.
+ private func closeWindowImmediately() {
+ guard let window = window else { return }
+
+ registerUndoForCloseWindow()
+
+ if let tabGroup = window.tabGroup, tabGroup.windows.count > 1 {
+ tabGroup.windows.forEach { window in
+ // Clear out the surfacetree to ensure there is no undo state.
+ // This prevents unnecessary undos registered since AppKit may
+ // process them on later ticks so we can't just disable undo registration.
+ if let controller = window.windowController as? TerminalController {
+ controller.surfaceTree = .init()
+ }
+
+ window.close()
+ }
+ } else {
+ window.close()
+ }
+ }
+
+ /// Registers undo for closing window(s), handling both single windows and tab groups.
+ private func registerUndoForCloseWindow() {
+ guard let undoManager, undoManager.isUndoRegistrationEnabled else { return }
guard let window else { return }
- window.styleMask = [
- // We need `titled` in the mask to get the normal window frame
- .titled,
+ // If we don't have a tab group or we don't have multiple tabs, then
+ // do a normal single window close.
+ guard let tabGroup = window.tabGroup,
+ tabGroup.windows.count > 1 else {
+ // No tabs, just save this window's state
+ if let undoState {
+ // Register undo action to restore the window
+ undoManager.setActionName("Close Window")
+ undoManager.registerUndo(
+ withTarget: ghostty,
+ expiresAfter: undoExpiration) { ghostty in
+ // Restore the undo state
+ let newController = TerminalController(ghostty, with: undoState)
+
+ // Register redo action
+ undoManager.registerUndo(
+ withTarget: newController,
+ expiresAfter: newController.undoExpiration) { target in
+ target.closeWindowImmediately()
+ }
+ }
+ }
+
+ return
+ }
- // Full size content view so we can extend
- // content in to the hidden titlebar's area
- .fullSizeContentView,
+ // Multiple windows in tab group - collect all undo states in sorted order
+ // by tab ordering. Also track which window was key.
+ let undoStates = tabGroup.windows
+ .compactMap { tabWindow -> UndoState? in
+ guard let controller = tabWindow.windowController as? TerminalController,
+ var undoState = controller.undoState else { return nil }
+ // Clear the tab group reference since it is unneeded. It should be
+ // garbage collected but we want to be extra sure we don't try to
+ // restore into it because we're going to recreate it.
+ undoState.tabGroup = nil
+ return undoState
+ }
+ .sorted { (lhs, rhs) in
+ switch (lhs.tabIndex, rhs.tabIndex) {
+ case let (l?, r?): return l < r
+ case (_?, nil): return true
+ case (nil, _?): return false
+ case (nil, nil): return true
+ }
+ }
+
+ // Find the index of the key window in our sorted states. This is a bit verbose
+ // but we only need this for this style of undo so we don't want to add it to
+ // UndoState.
+ let keyWindowIndex: Int?
+ if let keyWindow = tabGroup.windows.first(where: { $0.isKeyWindow }),
+ let keyController = keyWindow.windowController as? TerminalController,
+ let keyUndoState = keyController.undoState {
+ keyWindowIndex = undoStates.firstIndex {
+ $0.tabIndex == keyUndoState.tabIndex }
+ } else {
+ keyWindowIndex = nil
+ }
- .resizable,
- .closable,
- .miniaturizable,
- ]
+ // Register undo action to restore all windows
+ guard !undoStates.isEmpty else { return }
+
+ undoManager.setActionName("Close Window")
+ undoManager.registerUndo(
+ withTarget: ghostty,
+ expiresAfter: undoExpiration
+ ) { ghostty in
+ // Restore all windows in the tab group
+ let controllers = undoStates.map { undoState in
+ TerminalController(ghostty, with: undoState)
+ }
+
+ // The first controller becomes the parent window for all tabs.
+ // If we don't have a first controller (shouldn't be possible?)
+ // then we can't restore tabs.
+ guard let firstController = controllers.first else { return }
+
+ // Add all subsequent controllers as tabs to the first window
+ for controller in controllers.dropFirst() {
+ controller.showWindow(nil)
+ if let firstWindow = firstController.window,
+ let newWindow = controller.window {
+ firstWindow.addTabbedWindow(newWindow, ordered: .above)
+ }
+ }
+
+ // Make the appropriate window key. If we had a key window, restore it.
+ // Otherwise, make the last window key.
+ if let keyWindowIndex, keyWindowIndex < controllers.count {
+ controllers[keyWindowIndex].window?.makeKeyAndOrderFront(nil)
+ } else {
+ controllers.last?.window?.makeKeyAndOrderFront(nil)
+ }
- // Hide the title
- window.titleVisibility = .hidden
- window.titlebarAppearsTransparent = true
+ // Register redo action on the first controller
+ undoManager.registerUndo(
+ withTarget: firstController,
+ expiresAfter: firstController.undoExpiration
+ ) { target in
+ target.closeWindowImmediately()
+ }
+ }
+ }
- // Hide the traffic lights (window control buttons)
- window.standardWindowButton(.closeButton)?.isHidden = true
- window.standardWindowButton(.miniaturizeButton)?.isHidden = true
- window.standardWindowButton(.zoomButton)?.isHidden = true
+ /// Close all windows, asking for confirmation if necessary.
+ static func closeAllWindows() {
+ let needsConfirm: Bool = all.contains {
+ $0.surfaceTree.contains { $0.needsConfirmQuit }
+ }
- // Disallow tabbing if the titlebar is hidden, since that will (should) also hide the tab bar.
- window.tabbingMode = .disallowed
+ if (!needsConfirm) {
+ closeAllWindowsImmediately()
+ return
+ }
- // Nuke it from orbit -- hide the titlebar container entirely, just in case. There are
- // some operations that appear to bring back the titlebar visibility so this ensures
- // it is gone forever.
- if let themeFrame = window.contentView?.superview,
- let titleBarContainer = themeFrame.firstDescendant(withClassName: "NSTitlebarContainerView") {
- titleBarContainer.isHidden = true
+ // If we don't have a main window, we just close all windows because
+ // we have no window to show the modal on top of. I'm sure there's a way
+ // to do an app-level alert but I don't know how and this case should never
+ // really happen.
+ guard let alertWindow = preferredParent?.window else {
+ closeAllWindowsImmediately()
+ return
}
+
+ // If we need confirmation by any, show one confirmation for all windows
+ let alert = NSAlert()
+ alert.messageText = "Close All Windows?"
+ alert.informativeText = "All terminal sessions will be terminated."
+ alert.addButton(withTitle: "Close All Windows")
+ alert.addButton(withTitle: "Cancel")
+ alert.alertStyle = .warning
+ alert.beginSheetModal(for: alertWindow, completionHandler: { response in
+ if (response == .alertFirstButtonReturn) {
+ closeAllWindowsImmediately()
+ }
+ })
+ }
+
+ static private func closeAllWindowsImmediately() {
+ let undoManager = (NSApp.delegate as? AppDelegate)?.undoManager
+ undoManager?.beginUndoGrouping()
+ all.forEach { $0.closeWindowImmediately() }
+ undoManager?.setActionName("Close All Windows")
+ undoManager?.endUndoGrouping()
+ }
+
+ // MARK: Undo/Redo
+
+ /// The state that we require to recreate a TerminalController from an undo.
+ struct UndoState {
+ let frame: NSRect
+ let surfaceTree: SplitTree<Ghostty.SurfaceView>
+ let focusedSurface: UUID?
+ let tabIndex: Int?
+ weak var tabGroup: NSWindowTabGroup?
+ }
+
+ convenience init(_ ghostty: Ghostty.App,
+ with undoState: UndoState
+ ) {
+ self.init(ghostty, withSurfaceTree: undoState.surfaceTree)
+
+ // Show the window and restore its frame
+ showWindow(nil)
+ if let window {
+ window.setFrame(undoState.frame, display: true)
+
+ // If we have a tab group and index, restore the tab to its original position
+ if let tabGroup = undoState.tabGroup,
+ let tabIndex = undoState.tabIndex {
+ if tabIndex < tabGroup.windows.count {
+ // Find the window that is currently at that index
+ let currentWindow = tabGroup.windows[tabIndex]
+ currentWindow.addTabbedWindow(window, ordered: .below)
+ } else {
+ tabGroup.windows.last?.addTabbedWindow(window, ordered: .above)
+ }
+
+ // Make it the key window
+ window.makeKeyAndOrderFront(nil)
+ }
+
+ // Restore focus to the previously focused surface
+ if let focusedUUID = undoState.focusedSurface,
+ let focusTarget = surfaceTree.first(where: { $0.uuid == focusedUUID }) {
+ DispatchQueue.main.async {
+ Ghostty.moveFocus(to: focusTarget, from: nil)
+ }
+ }
+ }
+ }
+
+ /// The current undo state for this controller
+ var undoState: UndoState? {
+ guard let window else { return nil }
+ guard !surfaceTree.isEmpty else { return nil }
+ return .init(
+ frame: window.frame,
+ surfaceTree: surfaceTree,
+ focusedSurface: focusedSurface?.uuid,
+ tabIndex: window.tabGroup?.windows.firstIndex(of: window),
+ tabGroup: window.tabGroup)
+ }
+
+ //MARK: - NSWindowController
+
+ override func windowWillLoad() {
+ // We do NOT want to cascade because we handle this manually from the manager.
+ shouldCascadeWindows = false
}
override func windowDidLoad() {
super.windowDidLoad()
- guard let window = window as? TerminalWindow else { return }
+ guard let window else { return }
// Store our initial frame so we can know our default later.
initialFrame = window.frame
@@ -440,55 +837,18 @@ class TerminalController: BaseTerminalController {
window.identifier = .init(String(describing: TerminalWindowRestoration.self))
}
- // If window decorations are disabled, remove our title
- if (!config.windowDecorations) { window.styleMask.remove(.titled) }
-
// If we have only a single surface (no splits) and there is a default size then
// we should resize to that default size.
- if case let .leaf(leaf) = surfaceTree {
+ if case let .leaf(view) = surfaceTree.root {
// If this is our first surface then our focused surface will be nil
// so we force the focused surface to the leaf.
- focusedSurface = leaf.surface
+ focusedSurface = view
if let defaultSize {
window.setFrame(defaultSize, display: true)
}
}
- // Set our window positioning to coordinates if config value exists, otherwise
- // fallback to original centering behavior
- setInitialWindowPosition(
- x: config.windowPositionX,
- y: config.windowPositionY,
- windowDecorations: config.windowDecorations)
-
- // Make sure our theme is set on the window so styling is correct.
- if let windowTheme = config.windowTheme {
- window.windowTheme = .init(rawValue: windowTheme)
- }
-
- // Handle titlebar tabs config option. Something about what we do while setting up the
- // titlebar tabs interferes with the window restore process unless window.tabbingMode
- // is set to .preferred, so we set it, and switch back to automatic as soon as we can.
- if (config.macosTitlebarStyle == "tabs") {
- window.tabbingMode = .preferred
- window.titlebarTabs = true
- DispatchQueue.main.async {
- window.tabbingMode = .automatic
- }
- } else if (config.macosTitlebarStyle == "transparent") {
- window.transparentTabs = true
- }
-
- if window.hasStyledTabs {
- // Set the background color of the window
- let backgroundColor = NSColor(config.backgroundColor)
- window.backgroundColor = backgroundColor
-
- // This makes sure our titlebar renders correctly when there is a transparent background
- window.titlebarColor = backgroundColor.withAlphaComponent(config.backgroundOpacity)
- }
-
// Initialize our content view to the SwiftUI root
window.contentView = NSHostingView(rootView: TerminalView(
ghostty: self.ghostty,
@@ -496,11 +856,6 @@ class TerminalController: BaseTerminalController {
delegate: self
))
- // If our titlebar style is "hidden" we adjust the style appropriately
- if (config.macosTitlebarStyle == "hidden") {
- applyHiddenTitlebarStyle()
- }
-
// In various situations, macOS automatically tabs new windows. Ghostty handles
// its own tabbing so we DONT want this behavior. This detects this scenario and undoes
// it.
@@ -534,11 +889,58 @@ class TerminalController: BaseTerminalController {
ghostty.newTab(surface: surface)
}
- //MARK: - NSWindowDelegate
+ // MARK: NSWindowDelegate
+
+ // TabGroupCloseCoordinator.Controller
+ lazy private(set) var tabGroupCloseCoordinator = TabGroupCloseCoordinator()
+
+ override func windowShouldClose(_ sender: NSWindow) -> Bool {
+ tabGroupCloseCoordinator.windowShouldClose(sender) { [weak self] scope in
+ guard let self else { return }
+ switch (scope) {
+ case .tab: closeTab(nil)
+ case .window:
+ guard self.window?.isFirstWindowInTabGroup ?? false else { return }
+ closeWindow(nil)
+ }
+ }
+
+ // We will always explicitly close the window using the above
+ return false
+ }
override func windowWillClose(_ notification: Notification) {
super.windowWillClose(notification)
self.relabelTabs()
+
+ // If we remove a window, we reset the cascade point to the key window so that
+ // the next window cascade's from that one.
+ if let focusedWindow = NSApplication.shared.keyWindow {
+ // If we are NOT the focused window, then we are a tabbed window. If we
+ // are closing a tabbed window, we want to set the cascade point to be
+ // the next cascade point from this window.
+ if focusedWindow != window {
+ // The cascadeTopLeft call below should NOT move the window. Starting with
+ // macOS 15, we found that specifically when used with the new window snapping
+ // features of macOS 15, this WOULD move the frame. So we keep track of the
+ // old frame and restore it if necessary. Issue:
+ // https://github.com/ghostty-org/ghostty/issues/2565
+ let oldFrame = focusedWindow.frame
+
+ Self.lastCascadePoint = focusedWindow.cascadeTopLeft(from: NSZeroPoint)
+
+ if focusedWindow.frame != oldFrame {
+ focusedWindow.setFrame(oldFrame, display: true)
+ }
+
+ return
+ }
+
+ // If we are the focused window, then we set the last cascade point to
+ // our own frame so that it shows up in the same spot.
+ let frame = focusedWindow.frame
+ Self.lastCascadePoint = NSPoint(x: frame.minX, y: frame.maxY)
+ }
}
override func windowDidBecomeKey(_ notification: Notification) {
@@ -585,47 +987,24 @@ class TerminalController: BaseTerminalController {
ghostty.newTab(surface: surface)
}
- private func confirmClose(
- window: NSWindow,
- messageText: String,
- informativeText: String,
- completion: @escaping () -> Void
- ) {
- // If we need confirmation by any, show one confirmation for all windows
- // in the tab group.
- let alert = NSAlert()
- alert.messageText = messageText
- alert.informativeText = informativeText
- alert.addButton(withTitle: "Close")
- alert.addButton(withTitle: "Cancel")
- alert.alertStyle = .warning
- alert.beginSheetModal(for: window) { response in
- if response == .alertFirstButtonReturn {
- completion()
- }
- }
- }
-
@IBAction func closeTab(_ sender: Any?) {
guard let window = window else { return }
- guard window.tabGroup != nil else {
- // No tabs, no tab group, just perform a normal close.
- window.performClose(sender)
+ guard window.tabGroup?.windows.count ?? 0 > 1 else {
+ closeWindow(sender)
return
}
- if surfaceTree?.needsConfirmQuit() ?? false {
- confirmClose(
- window: window,
- messageText: "Close Tab?",
- informativeText: "The terminal still has a running process. If you close the tab the process will be killed."
- ) {
- window.close()
- }
+ guard surfaceTree.contains(where: { $0.needsConfirmQuit }) else {
+ closeTabImmediately()
return
}
- window.close()
+ confirmClose(
+ messageText: "Close Tab?",
+ informativeText: "The terminal still has a running process. If you close the tab the process will be killed."
+ ) {
+ self.closeTabImmediately()
+ }
}
@IBAction func returnToDefaultSize(_ sender: Any?) {
@@ -635,38 +1014,31 @@ class TerminalController: BaseTerminalController {
@IBAction override func closeWindow(_ sender: Any?) {
guard let window = window else { return }
- guard let tabGroup = window.tabGroup else {
- // No tabs, no tab group, just perform a normal close.
- window.performClose(sender)
- return
- }
- // If have one window then we just do a normal close
- if tabGroup.windows.count == 1 {
- window.performClose(sender)
- return
- }
+ // We need to check all the windows in our tab group for confirmation
+ // if we're closing the window. If we don't have a tabgroup for any
+ // reason we check ourselves.
+ let windows: [NSWindow] = window.tabGroup?.windows ?? [window]
// Check if any windows require close confirmation.
- let needsConfirm = tabGroup.windows.contains { tabWindow in
+ let needsConfirm = windows.contains { tabWindow in
guard let controller = tabWindow.windowController as? TerminalController else {
return false
}
- return controller.surfaceTree?.needsConfirmQuit() ?? false
+ return controller.surfaceTree.contains(where: { $0.needsConfirmQuit })
}
// If none need confirmation then we can just close all the windows.
if !needsConfirm {
- tabGroup.windows.forEach { $0.close() }
+ closeWindowImmediately()
return
}
confirmClose(
- window: window,
messageText: "Close Window?",
informativeText: "All terminal sessions in this window will be terminated."
) {
- tabGroup.windows.forEach { $0.close() }
+ self.closeWindowImmediately()
}
}
@@ -681,35 +1053,7 @@ class TerminalController: BaseTerminalController {
}
//MARK: - TerminalViewDelegate
-
- override func titleDidChange(to: String) {
- super.titleDidChange(to: to)
-
- guard let window = window as? TerminalWindow else { return }
-
- // Custom toolbar-based title used when titlebar tabs are enabled.
- if let toolbar = window.toolbar as? TerminalToolbar {
- if (window.titlebarTabs || derivedConfig.macosTitlebarStyle == "hidden") {
- // Updating the title text as above automatically reveals the
- // native title view in macOS 15.0 and above. Since we're using
- // a custom view instead, we need to re-hide it.
- window.titleVisibility = .hidden
- }
- toolbar.titleText = to
- }
- }
-
- override func surfaceTreeDidChange() {
- // Whenever our surface tree changes in any way (new split, close split, etc.)
- // we want to invalidate our state.
- invalidateRestorableState()
- }
-
- override func zoomStateDidChange(to: Bool) {
- guard let window = window as? TerminalWindow else { return }
- window.surfaceIsZoomed = to
- }
-
+
override func focusedSurfaceDidChange(to: Ghostty.SurfaceView?) {
super.focusedSurfaceDidChange(to: to)
@@ -842,19 +1186,19 @@ class TerminalController: BaseTerminalController {
@objc private func onCloseTab(notification: SwiftUI.Notification) {
guard let target = notification.object as? Ghostty.SurfaceView else { return }
- guard surfaceTree?.contains(view: target) ?? false else { return }
+ guard surfaceTree.contains(target) else { return }
closeTab(self)
}
@objc private func onCloseWindow(notification: SwiftUI.Notification) {
guard let target = notification.object as? Ghostty.SurfaceView else { return }
- guard surfaceTree?.contains(view: target) ?? false else { return }
+ guard surfaceTree.contains(target) else { return }
closeWindow(self)
}
@objc private func onResetWindowSize(notification: SwiftUI.Notification) {
guard let target = notification.object as? Ghostty.SurfaceView else { return }
- guard surfaceTree?.contains(view: target) ?? false else { return }
+ guard surfaceTree.contains(target) else { return }
returnToDefaultSize(nil)
}
@@ -875,36 +1219,29 @@ class TerminalController: BaseTerminalController {
toggleFullscreen(mode: fullscreenMode)
}
- @objc private func onEqualizeSplits(_ notification: Notification) {
- guard let target = notification.object as? Ghostty.SurfaceView else { return }
-
- // Check if target surface is in current controller's tree
- guard surfaceTree?.contains(view: target) ?? false else { return }
-
- if case .split(let container) = surfaceTree {
- _ = container.equalize()
- }
- }
-
struct DerivedConfig {
let backgroundColor: Color
+ let macosWindowButtons: Ghostty.MacOSWindowButtons
let macosTitlebarStyle: String
let maximize: Bool
init() {
self.backgroundColor = Color(NSColor.windowBackgroundColor)
+ self.macosWindowButtons = .visible
self.macosTitlebarStyle = "system"
self.maximize = false
}
init(_ config: Ghostty.Config) {
self.backgroundColor = config.backgroundColor
+ self.macosWindowButtons = config.macosWindowButtons
self.macosTitlebarStyle = config.macosTitlebarStyle
self.maximize = config.maximize
}
}
}
+// MARK: NSMenuItemValidation
extension TerminalController: NSMenuItemValidation {
func validateMenuItem(_ item: NSMenuItem) -> Bool {
@@ -940,4 +1277,3 @@ extension TerminalController: NSMenuItemValidation {
}
}
}
-
diff --git a/macos/Sources/Features/Terminal/TerminalManager.swift b/macos/Sources/Features/Terminal/TerminalManager.swift
deleted file mode 100644
index 07735cb58..000000000
--- a/macos/Sources/Features/Terminal/TerminalManager.swift
+++ /dev/null
@@ -1,372 +0,0 @@
-import Cocoa
-import SwiftUI
-import GhosttyKit
-import Combine
-
-/// Manages a set of terminal windows. This is effectively an array of TerminalControllers.
-/// This abstraction helps manage tabs and multi-window scenarios.
-class TerminalManager {
- struct Window {
- let controller: TerminalController
- let closePublisher: AnyCancellable
- }
-
- let ghostty: Ghostty.App
-
- /// The currently focused surface of the main window.
- var focusedSurface: Ghostty.SurfaceView? { mainWindow?.controller.focusedSurface }
-
- /// The set of windows we currently have.
- var windows: [Window] = []
-
- // Keep track of the last point that our window was launched at so that new
- // windows "cascade" over each other and don't just launch directly on top
- // of each other.
- private static var lastCascadePoint = NSPoint(x: 0, y: 0)
-
- /// Returns the main window of the managed window stack. If there is no window
- /// then an arbitrary window will be chosen.
- private var mainWindow: Window? {
- for window in windows {
- if (window.controller.window?.isMainWindow ?? false) {
- return window
- }
- }
-
- // If we have no main window, just use the last window.
- return windows.last
- }
-
- /// The configuration derived from the Ghostty config so we don't need to rely on references.
- private var derivedConfig: DerivedConfig
-
- init(_ ghostty: Ghostty.App) {
- self.ghostty = ghostty
- self.derivedConfig = DerivedConfig(ghostty.config)
-
- let center = NotificationCenter.default
- center.addObserver(
- self,
- selector: #selector(onNewTab),
- name: Ghostty.Notification.ghosttyNewTab,
- object: nil)
- center.addObserver(
- self,
- selector: #selector(onNewWindow),
- name: Ghostty.Notification.ghosttyNewWindow,
- object: nil)
- center.addObserver(
- self,
- selector: #selector(ghosttyConfigDidChange(_:)),
- name: .ghosttyConfigDidChange,
- object: nil)
- }
-
- deinit {
- let center = NotificationCenter.default
- center.removeObserver(self)
- }
-
- // MARK: - Window Management
-
- /// Create a new terminal window.
- func newWindow(withBaseConfig base: Ghostty.SurfaceConfiguration? = nil) {
- let c = createWindow(withBaseConfig: base)
- let window = c.window!
-
- // If the previous focused window was native fullscreen, the new window also
- // becomes native fullscreen.
- if let parent = focusedSurface?.window,
- parent.styleMask.contains(.fullScreen) {
- window.toggleFullScreen(nil)
- } else if derivedConfig.windowFullscreen {
- switch (derivedConfig.windowFullscreenMode) {
- case .native:
- // Native has to be done immediately so that our stylemask contains
- // fullscreen for the logic later in this method.
- c.toggleFullscreen(mode: .native)
-
- case .nonNative, .nonNativeVisibleMenu, .nonNativePaddedNotch:
- // If we're non-native then we have to do it on a later loop
- // so that the content view is setup.
- DispatchQueue.main.async {
- c.toggleFullscreen(mode: self.derivedConfig.windowFullscreenMode)
- }
- }
- }
-
- // All new_window actions force our app to be active.
- NSApp.activate(ignoringOtherApps: true)
-
- // We're dispatching this async because otherwise the lastCascadePoint doesn't
- // take effect. Our best theory is there is some next-event-loop-tick logic
- // that Cocoa is doing that we need to be after.
- DispatchQueue.main.async {
- // Only cascade if we aren't fullscreen.
- if (!window.styleMask.contains(.fullScreen)) {
- Self.lastCascadePoint = window.cascadeTopLeft(from: Self.lastCascadePoint)
- }
-
- c.showWindow(self)
- }
- }
-
- /// Creates a new tab in the current main window. If there are no windows, a window
- /// is created.
- func newTab(withBaseConfig base: Ghostty.SurfaceConfiguration? = nil) {
- // If there is no main window, just create a new window
- guard let parent = mainWindow?.controller.window else {
- newWindow(withBaseConfig: base)
- return
- }
-
- // Create a new window and add it to the parent
- newTab(to: parent, withBaseConfig: base)
- }
-
- private func newTab(to parent: NSWindow, withBaseConfig base: Ghostty.SurfaceConfiguration?) {
- // Making sure that we're dealing with a TerminalController
- guard parent.windowController is TerminalController else { return }
-
- // If our parent is in non-native fullscreen, then new tabs do not work.
- // See: https://github.com/mitchellh/ghostty/issues/392
- if let controller = parent.windowController as? TerminalController,
- let fullscreenStyle = controller.fullscreenStyle,
- fullscreenStyle.isFullscreen && !fullscreenStyle.supportsTabs {
- let alert = NSAlert()
- alert.messageText = "Cannot Create New Tab"
- alert.informativeText = "New tabs are unsupported while in non-native fullscreen. Exit fullscreen and try again."
- alert.addButton(withTitle: "OK")
- alert.alertStyle = .warning
- alert.beginSheetModal(for: parent)
- return
- }
-
- // Create a new window and add it to the parent
- let controller = createWindow(withBaseConfig: base)
- let window = controller.window!
-
- // If the parent is miniaturized, then macOS exhibits really strange behaviors
- // so we have to bring it back out.
- if (parent.isMiniaturized) { parent.deminiaturize(self) }
-
- // If our parent tab group already has this window, macOS added it and
- // we need to remove it so we can set the correct order in the next line.
- // If we don't do this, macOS gets really confused and the tabbedWindows
- // state becomes incorrect.
- //
- // At the time of writing this code, the only known case this happens
- // is when the "+" button is clicked in the tab bar.
- if let tg = parent.tabGroup, tg.windows.firstIndex(of: window) != nil {
- tg.removeWindow(window)
- }
-
- // Our windows start out invisible. We need to make it visible. If we
- // don't do this then various features such as window blur won't work because
- // the macOS APIs only work on a visible window.
- controller.showWindow(self)
-
- // If we have the "hidden" titlebar style we want to create new
- // tabs as windows instead, so just skip adding it to the parent.
- if (derivedConfig.macosTitlebarStyle != "hidden") {
- // Add the window to the tab group and show it.
- switch derivedConfig.windowNewTabPosition {
- case "end":
- // If we already have a tab group and we want the new tab to open at the end,
- // then we use the last window in the tab group as the parent.
- if let last = parent.tabGroup?.windows.last {
- last.addTabbedWindow(window, ordered: .above)
- } else {
- fallthrough
- }
- case "current": fallthrough
- default:
- parent.addTabbedWindow(window, ordered: .above)
-
- }
- }
-
- window.makeKeyAndOrderFront(self)
-
- // It takes an event loop cycle until the macOS tabGroup state becomes
- // consistent which causes our tab labeling to be off when the "+" button
- // is used in the tab bar. This fixes that. If we can find a more robust
- // solution we should do that.
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { controller.relabelTabs() }
- }
-
- /// Creates a window controller, adds it to our managed list, and returns it.
- func createWindow(withBaseConfig base: Ghostty.SurfaceConfiguration? = nil,
- withSurfaceTree tree: Ghostty.SplitNode? = nil) -> TerminalController {
- // Initialize our controller to load the window
- let c = TerminalController(ghostty, withBaseConfig: base, withSurfaceTree: tree)
-
- // Create a listener for when the window is closed so we can remove it.
- let pubClose = NotificationCenter.default.publisher(
- for: NSWindow.willCloseNotification,
- object: c.window!
- ).sink { notification in
- guard let window = notification.object as? NSWindow else { return }
- guard let c = window.windowController as? TerminalController else { return }
- self.removeWindow(c)
- }
-
- // Keep track of every window we manage
- windows.append(Window(
- controller: c,
- closePublisher: pubClose
- ))
-
- return c
- }
-
- func removeWindow(_ controller: TerminalController) {
- // Remove it from our managed set
- guard let idx = self.windows.firstIndex(where: { $0.controller == controller }) else { return }
- let w = self.windows[idx]
- self.windows.remove(at: idx)
-
- // Ensure any publishers we have are cancelled
- w.closePublisher.cancel()
-
- // If we remove a window, we reset the cascade point to the key window so that
- // the next window cascade's from that one.
- if let focusedWindow = NSApplication.shared.keyWindow {
- // If we are NOT the focused window, then we are a tabbed window. If we
- // are closing a tabbed window, we want to set the cascade point to be
- // the next cascade point from this window.
- if focusedWindow != controller.window {
- // The cascadeTopLeft call below should NOT move the window. Starting with
- // macOS 15, we found that specifically when used with the new window snapping
- // features of macOS 15, this WOULD move the frame. So we keep track of the
- // old frame and restore it if necessary. Issue:
- // https://github.com/ghostty-org/ghostty/issues/2565
- let oldFrame = focusedWindow.frame
-
- Self.lastCascadePoint = focusedWindow.cascadeTopLeft(from: NSZeroPoint)
-
- if focusedWindow.frame != oldFrame {
- focusedWindow.setFrame(oldFrame, display: true)
- }
-
- return
- }
-
- // If we are the focused window, then we set the last cascade point to
- // our own frame so that it shows up in the same spot.
- let frame = focusedWindow.frame
- Self.lastCascadePoint = NSPoint(x: frame.minX, y: frame.maxY)
- }
-
- // I don't think we strictly have to do this but if a window is
- // closed I want to make sure that the app state is invalided so
- // we don't reopen closed windows.
- NSApplication.shared.invalidateRestorableState()
- }
-
- /// Close all windows, asking for confirmation if necessary.
- func closeAllWindows() {
- var needsConfirm: Bool = false
- for w in self.windows {
- if (w.controller.surfaceTree?.needsConfirmQuit() ?? false) {
- needsConfirm = true
- break
- }
- }
-
- if (!needsConfirm) {
- for w in self.windows {
- w.controller.close()
- }
-
- return
- }
-
- // If we don't have a main window, we just close all windows because
- // we have no window to show the modal on top of. I'm sure there's a way
- // to do an app-level alert but I don't know how and this case should never
- // really happen.
- guard let alertWindow = mainWindow?.controller.window else {
- for w in self.windows {
- w.controller.close()
- }
-
- return
- }
-
- // If we need confirmation by any, show one confirmation for all windows
- let alert = NSAlert()
- alert.messageText = "Close All Windows?"
- alert.informativeText = "All terminal sessions will be terminated."
- alert.addButton(withTitle: "Close All Windows")
- alert.addButton(withTitle: "Cancel")
- alert.alertStyle = .warning
- alert.beginSheetModal(for: alertWindow, completionHandler: { response in
- if (response == .alertFirstButtonReturn) {
- for w in self.windows {
- w.controller.close()
- }
- }
- })
- }
-
- /// Relabels all the tabs with the proper keyboard shortcut.
- func relabelAllTabs() {
- for w in windows {
- w.controller.relabelTabs()
- }
- }
-
- // MARK: - Notifications
-
- @objc private func onNewWindow(notification: SwiftUI.Notification) {
- let configAny = notification.userInfo?[Ghostty.Notification.NewSurfaceConfigKey]
- let config = configAny as? Ghostty.SurfaceConfiguration
- self.newWindow(withBaseConfig: config)
- }
-
- @objc private func onNewTab(notification: SwiftUI.Notification) {
- guard let surfaceView = notification.object as? Ghostty.SurfaceView else { return }
- guard let window = surfaceView.window else { return }
-
- let configAny = notification.userInfo?[Ghostty.Notification.NewSurfaceConfigKey]
- let config = configAny as? Ghostty.SurfaceConfiguration
-
- self.newTab(to: window, withBaseConfig: config)
- }
-
- @objc private func ghosttyConfigDidChange(_ notification: Notification) {
- // We only care if the configuration is a global configuration, not a
- // surface-specific one.
- guard notification.object == nil else { return }
-
- // Get our managed configuration object out
- guard let config = notification.userInfo?[
- Notification.Name.GhosttyConfigChangeKey
- ] as? Ghostty.Config else { return }
-
- // Update our derived config
- self.derivedConfig = DerivedConfig(config)
- }
-
- private struct DerivedConfig {
- let windowFullscreen: Bool
- let windowFullscreenMode: FullscreenMode
- let macosTitlebarStyle: String
- let windowNewTabPosition: String
-
- init() {
- self.windowFullscreen = false
- self.windowFullscreenMode = .native
- self.macosTitlebarStyle = "transparent"
- self.windowNewTabPosition = ""
- }
-
- init(_ config: Ghostty.Config) {
- self.windowFullscreen = config.windowFullscreen
- self.windowFullscreenMode = config.windowFullscreenMode
- self.macosTitlebarStyle = config.macosTitlebarStyle
- self.windowNewTabPosition = config.windowNewTabPosition
- }
- }
-}
diff --git a/macos/Sources/Features/Terminal/TerminalRestorable.swift b/macos/Sources/Features/Terminal/TerminalRestorable.swift
index b9d9b0ac0..9d9b7ffb1 100644
--- a/macos/Sources/Features/Terminal/TerminalRestorable.swift
+++ b/macos/Sources/Features/Terminal/TerminalRestorable.swift
@@ -4,10 +4,10 @@ import Cocoa
class TerminalRestorableState: Codable {
static let selfKey = "state"
static let versionKey = "version"
- static let version: Int = 2
+ static let version: Int = 3
let focusedSurface: String?
- let surfaceTree: Ghostty.SplitNode?
+ let surfaceTree: SplitTree<Ghostty.SurfaceView>
init(from controller: TerminalController) {
self.focusedSurface = controller.focusedSurface?.uuid.uuidString
@@ -83,18 +83,29 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration {
// can be found for events from libghostty. This uses the low-level
// createWindow so that AppKit can place the window wherever it should
// be.
- let c = appDelegate.terminalManager.createWindow(withSurfaceTree: state.surfaceTree)
+ let c = TerminalController.init(
+ appDelegate.ghostty,
+ withSurfaceTree: state.surfaceTree)
guard let window = c.window else {
completionHandler(nil, TerminalRestoreError.windowDidNotLoad)
return
}
// Setup our restored state on the controller
- if let focusedStr = state.focusedSurface,
- let focusedUUID = UUID(uuidString: focusedStr),
- let view = c.surfaceTree?.findUUID(uuid: focusedUUID) {
- c.focusedSurface = view
- restoreFocus(to: view, inWindow: window)
+ // Find the focused surface in surfaceTree
+ if let focusedStr = state.focusedSurface {
+ var foundView: Ghostty.SurfaceView?
+ for view in c.surfaceTree {
+ if view.uuid.uuidString == focusedStr {
+ foundView = view
+ break
+ }
+ }
+
+ if let view = foundView {
+ c.focusedSurface = view
+ restoreFocus(to: view, inWindow: window)
+ }
}
completionHandler(window, nil)
diff --git a/macos/Sources/Features/Terminal/TerminalToolbar.swift b/macos/Sources/Features/Terminal/TerminalToolbar.swift
deleted file mode 100644
index aa4ca31cd..000000000
--- a/macos/Sources/Features/Terminal/TerminalToolbar.swift
+++ /dev/null
@@ -1,120 +0,0 @@
-import Cocoa
-
-// Custom NSToolbar subclass that displays a centered window title,
-// in order to accommodate the titlebar tabs feature.
-class TerminalToolbar: NSToolbar, NSToolbarDelegate {
- private let titleTextField = CenteredDynamicLabel(labelWithString: "👻 Ghostty")
-
- var titleText: String {
- get {
- titleTextField.stringValue
- }
-
- set {
- titleTextField.stringValue = newValue
- }
- }
-
- var titleFont: NSFont? {
- get {
- titleTextField.font
- }
-
- set {
- titleTextField.font = newValue
- }
- }
-
- override init(identifier: NSToolbar.Identifier) {
- super.init(identifier: identifier)
-
- delegate = self
- centeredItemIdentifiers.insert(.titleText)
- }
-
- func toolbar(_ toolbar: NSToolbar,
- itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier,
- willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem? {
- var item: NSToolbarItem
-
- switch itemIdentifier {
- case .titleText:
- item = NSToolbarItem(itemIdentifier: .titleText)
- item.view = self.titleTextField
- item.visibilityPriority = .user
-
- // This ensures the title text field doesn't disappear when shrinking the view
- self.titleTextField.translatesAutoresizingMaskIntoConstraints = false
- self.titleTextField.setContentHuggingPriority(.defaultLow, for: .horizontal)
- self.titleTextField.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
-
- // Add constraints to the toolbar item's view
- NSLayoutConstraint.activate([
- // Set the height constraint to match the toolbar's height
- self.titleTextField.heightAnchor.constraint(equalToConstant: 22), // Adjust as needed
- ])
-
- item.isEnabled = true
- case .resetZoom:
- item = NSToolbarItem(itemIdentifier: .resetZoom)
- default:
- item = NSToolbarItem(itemIdentifier: itemIdentifier)
- }
-
- return item
- }
-
- func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
- return [.titleText, .flexibleSpace, .space, .resetZoom]
- }
-
- func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
- // These space items are here to ensure that the title remains centered when it starts
- // getting smaller than the max size so starts clipping. Lucky for us, two of the
- // built-in spacers plus the un-zoom button item seems to exactly match the space
- // on the left that's reserved for the window buttons.
- return [.flexibleSpace, .titleText, .flexibleSpace]
- }
-}
-
-/// A label that expands to fit whatever text you put in it and horizontally centers itself in the current window.
-fileprivate class CenteredDynamicLabel: NSTextField {
- override func viewDidMoveToSuperview() {
- // Configure the text field
- isEditable = false
- isBordered = false
- drawsBackground = false
- alignment = .center
- lineBreakMode = .byTruncatingTail
- cell?.truncatesLastVisibleLine = true
-
- // Use Auto Layout
- translatesAutoresizingMaskIntoConstraints = false
-
- // Set content hugging and compression resistance priorities
- setContentHuggingPriority(.defaultLow, for: .horizontal)
- setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
- }
-
- // Vertically center the text
- override func draw(_ dirtyRect: NSRect) {
- guard let attributedString = self.attributedStringValue.mutableCopy() as? NSMutableAttributedString else {
- super.draw(dirtyRect)
- return
- }
-
- let textSize = attributedString.size()
-
- let yOffset = (self.bounds.height - textSize.height) / 2 - 1 // -1 to center it better
-
- let centeredRect = NSRect(x: self.bounds.origin.x, y: self.bounds.origin.y + yOffset,
- width: self.bounds.width, height: textSize.height)
-
- attributedString.draw(in: centeredRect)
- }
-}
-
-extension NSToolbarItem.Identifier {
- static let resetZoom = NSToolbarItem.Identifier("ResetZoom")
- static let titleText = NSToolbarItem.Identifier("TitleText")
-}
diff --git a/macos/Sources/Features/Terminal/TerminalView.swift b/macos/Sources/Features/Terminal/TerminalView.swift
index 7caceb071..b5be0ae42 100644
--- a/macos/Sources/Features/Terminal/TerminalView.swift
+++ b/macos/Sources/Features/Terminal/TerminalView.swift
@@ -14,15 +14,11 @@ protocol TerminalViewDelegate: AnyObject {
/// The cell size changed.
func cellSizeDidChange(to: NSSize)
- /// The surface tree did change in some way, i.e. a split was added, removed, etc. This is
- /// not called initially.
- func surfaceTreeDidChange()
-
- /// This is called when a split is zoomed.
- func zoomStateDidChange(to: Bool)
-
/// Perform an action. At the time of writing this is only triggered by the command palette.
func performAction(_ action: String, on: Ghostty.SurfaceView)
+
+ /// A split is resizing to a given value.
+ func splitDidResize(node: SplitTree<Ghostty.SurfaceView>.Node, to newRatio: Double)
}
/// The view model is a required implementation for TerminalView callers. This contains
@@ -31,7 +27,7 @@ protocol TerminalViewDelegate: AnyObject {
protocol TerminalViewModel: ObservableObject {
/// The tree of terminal surfaces (splits) within the view. This is mutated by TerminalView
/// and children. This should be @Published.
- var surfaceTree: Ghostty.SplitNode? { get set }
+ var surfaceTree: SplitTree<Ghostty.SurfaceView> { get set }
/// The command palette state.
var commandPaletteIsShowing: Bool { get set }
@@ -57,7 +53,6 @@ struct TerminalView<ViewModel: TerminalViewModel>: View {
// Various state values sent back up from the currently focused terminals.
@FocusedValue(\.ghosttySurfaceView) private var focusedSurface
@FocusedValue(\.ghosttySurfacePwd) private var surfacePwd
- @FocusedValue(\.ghosttySurfaceZoomed) private var zoomedSplit
@FocusedValue(\.ghosttySurfaceCellSize) private var cellSize
// The pwd of the focused surface as a URL
@@ -81,7 +76,9 @@ struct TerminalView<ViewModel: TerminalViewModel>: View {
DebugBuildWarningView()
}
- Ghostty.TerminalSplit(node: $viewModel.surfaceTree)
+ TerminalSplitTreeView(
+ tree: viewModel.surfaceTree,
+ onResize: { delegate?.splitDidResize(node: $0, to: $1) })
.environmentObject(ghostty)
.focused($focused)
.onAppear { self.focused = true }
@@ -100,15 +97,6 @@ struct TerminalView<ViewModel: TerminalViewModel>: View {
guard let size = newValue else { return }
self.delegate?.cellSizeDidChange(to: size)
}
- .onChange(of: viewModel.surfaceTree?.hashValue) { _ in
- // This is funky, but its the best way I could think of to detect
- // ANY CHANGE within the deeply nested surface tree -- detecting a change
- // in the hash value.
- self.delegate?.surfaceTreeDidChange()
- }
- .onChange(of: zoomedSplit) { newValue in
- self.delegate?.zoomStateDidChange(to: newValue ?? false)
- }
}
// Ignore safe area to extend up in to the titlebar region if we have the "hidden" titlebar style
.ignoresSafeArea(.container, edges: ghostty.config.macosTitlebarStyle == "hidden" ? .top : [])
@@ -151,6 +139,10 @@ struct DebugBuildWarningView: View {
}
.background(Color(.windowBackgroundColor))
.frame(maxWidth: .infinity)
+ .accessibilityElement(children: .combine)
+ .accessibilityLabel("Debug build warning")
+ .accessibilityValue("Debug builds of Ghostty are very slow and you may experience performance problems. Debug builds are only recommended during development.")
+ .accessibilityAddTraits(.isStaticText)
.onTapGesture {
isPopover = true
}
diff --git a/macos/Sources/Features/Terminal/Window Styles/HiddenTitlebarTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/HiddenTitlebarTerminalWindow.swift
new file mode 100644
index 000000000..5f4d6b177
--- /dev/null
+++ b/macos/Sources/Features/Terminal/Window Styles/HiddenTitlebarTerminalWindow.swift
@@ -0,0 +1,89 @@
+import AppKit
+
+class HiddenTitlebarTerminalWindow: TerminalWindow {
+ override func awakeFromNib() {
+ super.awakeFromNib()
+
+ // Setup our initial style
+ reapplyHiddenStyle()
+
+ // Notifications
+ NotificationCenter.default.addObserver(
+ self,
+ selector: #selector(fullscreenDidExit(_:)),
+ name: .fullscreenDidExit,
+ object: nil)
+ }
+
+ deinit {
+ NotificationCenter.default.removeObserver(self)
+ }
+
+ /// Apply the hidden titlebar style.
+ private func reapplyHiddenStyle() {
+ styleMask = [
+ // We need `titled` in the mask to get the normal window frame
+ .titled,
+
+ // Full size content view so we can extend
+ // content in to the hidden titlebar's area
+ .fullSizeContentView,
+
+ .resizable,
+ .closable,
+ .miniaturizable,
+ ]
+
+ // Hide the title
+ titleVisibility = .hidden
+ titlebarAppearsTransparent = true
+
+ // Hide the traffic lights (window control buttons)
+ standardWindowButton(.closeButton)?.isHidden = true
+ standardWindowButton(.miniaturizeButton)?.isHidden = true
+ standardWindowButton(.zoomButton)?.isHidden = true
+
+ // Disallow tabbing if the titlebar is hidden, since that will (should) also hide the tab bar.
+ tabbingMode = .disallowed
+
+ // Nuke it from orbit -- hide the titlebar container entirely, just in case. There are
+ // some operations that appear to bring back the titlebar visibility so this ensures
+ // it is gone forever.
+ if let themeFrame = contentView?.superview,
+ let titleBarContainer = themeFrame.firstDescendant(withClassName: "NSTitlebarContainerView") {
+ titleBarContainer.isHidden = true
+ }
+ }
+
+ // MARK: NSWindow
+
+ override var title: String {
+ didSet {
+ // Updating the title text as above automatically reveals the
+ // native title view in macOS 15.0 and above. Since we're using
+ // a custom view instead, we need to re-hide it.
+ reapplyHiddenStyle()
+ }
+ }
+
+ // We override this so that with the hidden titlebar style the titlebar
+ // area is not draggable.
+ override var contentLayoutRect: CGRect {
+ var rect = super.contentLayoutRect
+ rect.origin.y = 0
+ rect.size.height = self.frame.height
+ return rect
+ }
+
+ // MARK: Notifications
+
+ @objc private func fullscreenDidExit(_ notification: Notification) {
+ // Make sure they're talking about our window
+ guard let fullscreen = notification.object as? FullscreenBase else { return }
+ guard fullscreen.window == self else { return }
+
+ // On exit we need to reapply the style because macOS breaks it usually.
+ // This is safe to call repeatedly so if its not broken its still safe.
+ reapplyHiddenStyle()
+ }
+}
diff --git a/macos/Sources/Features/Terminal/Terminal.xib b/macos/Sources/Features/Terminal/Window Styles/Terminal.xib
index 65b03b6eb..cfbb2221c 100644
--- a/macos/Sources/Features/Terminal/Terminal.xib
+++ b/macos/Sources/Features/Terminal/Window Styles/Terminal.xib
@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
-<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="23094" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
+<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="24093.7" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
<dependencies>
<deployment identifier="macosx"/>
- <plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="23094"/>
+ <plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="24093.7"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
@@ -17,10 +17,10 @@
<windowStyleMask key="styleMask" titled="YES" closable="YES" miniaturizable="YES" resizable="YES"/>
<windowPositionMask key="initialPositionMask" leftStrut="YES" rightStrut="YES" topStrut="YES" bottomStrut="YES"/>
<rect key="contentRect" x="0.0" y="0.0" width="800" height="600"/>
- <rect key="screenRect" x="0.0" y="0.0" width="3008" height="1667"/>
+ <rect key="screenRect" x="0.0" y="0.0" width="3008" height="1661"/>
<view key="contentView" wantsLayer="YES" id="EiT-Mj-1SZ">
<rect key="frame" x="0.0" y="0.0" width="800" height="600"/>
- <autoresizingMask key="autoresizingMask"/>
+ <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
</view>
<connections>
<outlet property="delegate" destination="-2" id="tG2-b7-nb8"/>
diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalHiddenTitlebar.xib b/macos/Sources/Features/Terminal/Window Styles/TerminalHiddenTitlebar.xib
new file mode 100644
index 000000000..eb4675657
--- /dev/null
+++ b/macos/Sources/Features/Terminal/Window Styles/TerminalHiddenTitlebar.xib
@@ -0,0 +1,31 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="24093.7" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
+ <dependencies>
+ <deployment identifier="macosx"/>
+ <plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="24093.7"/>
+ <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
+ </dependencies>
+ <objects>
+ <customObject id="-2" userLabel="File's Owner" customClass="TerminalController" customModule="Ghostty" customModuleProvider="target">
+ <connections>
+ <outlet property="window" destination="QvC-M9-y7g" id="cg9-Ep-qHg"/>
+ </connections>
+ </customObject>
+ <customObject id="-1" userLabel="First Responder" customClass="FirstResponder"/>
+ <customObject id="-3" userLabel="Application" customClass="NSObject"/>
+ <window title="👻 Ghostty" allowsToolTipsWhenApplicationIsInactive="NO" autorecalculatesKeyViewLoop="NO" releasedWhenClosed="NO" visibleAtLaunch="NO" animationBehavior="default" id="QvC-M9-y7g" customClass="HiddenTitlebarTerminalWindow" customModule="Ghostty" customModuleProvider="target">
+ <windowStyleMask key="styleMask" titled="YES" closable="YES" miniaturizable="YES" resizable="YES"/>
+ <windowPositionMask key="initialPositionMask" leftStrut="YES" rightStrut="YES" topStrut="YES" bottomStrut="YES"/>
+ <rect key="contentRect" x="0.0" y="0.0" width="800" height="600"/>
+ <rect key="screenRect" x="0.0" y="0.0" width="3008" height="1661"/>
+ <view key="contentView" wantsLayer="YES" id="EiT-Mj-1SZ">
+ <rect key="frame" x="0.0" y="0.0" width="800" height="600"/>
+ <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
+ </view>
+ <connections>
+ <outlet property="delegate" destination="-2" id="tG2-b7-nb8"/>
+ </connections>
+ <point key="canvasLocation" x="132" y="-82"/>
+ </window>
+ </objects>
+</document>
diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalTabsTitlebarTahoe.xib b/macos/Sources/Features/Terminal/Window Styles/TerminalTabsTitlebarTahoe.xib
new file mode 100644
index 000000000..deaeded9f
--- /dev/null
+++ b/macos/Sources/Features/Terminal/Window Styles/TerminalTabsTitlebarTahoe.xib
@@ -0,0 +1,31 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="24093.7" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
+ <dependencies>
+ <deployment identifier="macosx"/>
+ <plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="24093.7"/>
+ <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
+ </dependencies>
+ <objects>
+ <customObject id="-2" userLabel="File's Owner" customClass="TerminalController" customModule="Ghostty" customModuleProvider="target">
+ <connections>
+ <outlet property="window" destination="QvC-M9-y7g" id="cg9-Ep-qHg"/>
+ </connections>
+ </customObject>
+ <customObject id="-1" userLabel="First Responder" customClass="FirstResponder"/>
+ <customObject id="-3" userLabel="Application" customClass="NSObject"/>
+ <window title="👻 Ghostty" allowsToolTipsWhenApplicationIsInactive="NO" autorecalculatesKeyViewLoop="NO" releasedWhenClosed="NO" visibleAtLaunch="NO" animationBehavior="default" id="QvC-M9-y7g" customClass="TitlebarTabsTahoeTerminalWindow" customModule="Ghostty" customModuleProvider="target">
+ <windowStyleMask key="styleMask" titled="YES" closable="YES" miniaturizable="YES" resizable="YES"/>
+ <windowPositionMask key="initialPositionMask" leftStrut="YES" rightStrut="YES" topStrut="YES" bottomStrut="YES"/>
+ <rect key="contentRect" x="0.0" y="0.0" width="800" height="600"/>
+ <rect key="screenRect" x="0.0" y="0.0" width="3008" height="1661"/>
+ <view key="contentView" wantsLayer="YES" id="EiT-Mj-1SZ">
+ <rect key="frame" x="0.0" y="0.0" width="800" height="600"/>
+ <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
+ </view>
+ <connections>
+ <outlet property="delegate" destination="-2" id="tG2-b7-nb8"/>
+ </connections>
+ <point key="canvasLocation" x="132" y="-82"/>
+ </window>
+ </objects>
+</document>
diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalTabsTitlebarVentura.xib b/macos/Sources/Features/Terminal/Window Styles/TerminalTabsTitlebarVentura.xib
new file mode 100644
index 000000000..bf53a4510
--- /dev/null
+++ b/macos/Sources/Features/Terminal/Window Styles/TerminalTabsTitlebarVentura.xib
@@ -0,0 +1,31 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="24093.7" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
+ <dependencies>
+ <deployment identifier="macosx"/>
+ <plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="24093.7"/>
+ <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
+ </dependencies>
+ <objects>
+ <customObject id="-2" userLabel="File's Owner" customClass="TerminalController" customModule="Ghostty" customModuleProvider="target">
+ <connections>
+ <outlet property="window" destination="QvC-M9-y7g" id="cg9-Ep-qHg"/>
+ </connections>
+ </customObject>
+ <customObject id="-1" userLabel="First Responder" customClass="FirstResponder"/>
+ <customObject id="-3" userLabel="Application" customClass="NSObject"/>
+ <window title="👻 Ghostty" allowsToolTipsWhenApplicationIsInactive="NO" autorecalculatesKeyViewLoop="NO" releasedWhenClosed="NO" visibleAtLaunch="NO" animationBehavior="default" id="QvC-M9-y7g" customClass="TitlebarTabsVenturaTerminalWindow" customModule="Ghostty" customModuleProvider="target">
+ <windowStyleMask key="styleMask" titled="YES" closable="YES" miniaturizable="YES" resizable="YES"/>
+ <windowPositionMask key="initialPositionMask" leftStrut="YES" rightStrut="YES" topStrut="YES" bottomStrut="YES"/>
+ <rect key="contentRect" x="0.0" y="0.0" width="800" height="600"/>
+ <rect key="screenRect" x="0.0" y="0.0" width="3008" height="1661"/>
+ <view key="contentView" wantsLayer="YES" id="EiT-Mj-1SZ">
+ <rect key="frame" x="0.0" y="0.0" width="800" height="600"/>
+ <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
+ </view>
+ <connections>
+ <outlet property="delegate" destination="-2" id="tG2-b7-nb8"/>
+ </connections>
+ <point key="canvasLocation" x="132" y="-82"/>
+ </window>
+ </objects>
+</document>
diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalTransparentTitlebar.xib b/macos/Sources/Features/Terminal/Window Styles/TerminalTransparentTitlebar.xib
new file mode 100644
index 000000000..25922e2f3
--- /dev/null
+++ b/macos/Sources/Features/Terminal/Window Styles/TerminalTransparentTitlebar.xib
@@ -0,0 +1,31 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="24093.7" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
+ <dependencies>
+ <deployment identifier="macosx"/>
+ <plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="24093.7"/>
+ <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
+ </dependencies>
+ <objects>
+ <customObject id="-2" userLabel="File's Owner" customClass="TerminalController" customModule="Ghostty" customModuleProvider="target">
+ <connections>
+ <outlet property="window" destination="QvC-M9-y7g" id="cg9-Ep-qHg"/>
+ </connections>
+ </customObject>
+ <customObject id="-1" userLabel="First Responder" customClass="FirstResponder"/>
+ <customObject id="-3" userLabel="Application" customClass="NSObject"/>
+ <window title="👻 Ghostty" allowsToolTipsWhenApplicationIsInactive="NO" autorecalculatesKeyViewLoop="NO" releasedWhenClosed="NO" visibleAtLaunch="NO" animationBehavior="default" id="QvC-M9-y7g" customClass="TransparentTitlebarTerminalWindow" customModule="Ghostty" customModuleProvider="target">
+ <windowStyleMask key="styleMask" titled="YES" closable="YES" miniaturizable="YES" resizable="YES"/>
+ <windowPositionMask key="initialPositionMask" leftStrut="YES" rightStrut="YES" topStrut="YES" bottomStrut="YES"/>
+ <rect key="contentRect" x="0.0" y="0.0" width="800" height="600"/>
+ <rect key="screenRect" x="0.0" y="0.0" width="3008" height="1661"/>
+ <view key="contentView" wantsLayer="YES" id="EiT-Mj-1SZ">
+ <rect key="frame" x="0.0" y="0.0" width="800" height="600"/>
+ <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
+ </view>
+ <connections>
+ <outlet property="delegate" destination="-2" id="tG2-b7-nb8"/>
+ </connections>
+ <point key="canvasLocation" x="132" y="-82"/>
+ </window>
+ </objects>
+</document>
diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift
new file mode 100644
index 000000000..cec85f06e
--- /dev/null
+++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift
@@ -0,0 +1,480 @@
+import AppKit
+import SwiftUI
+import GhosttyKit
+
+/// The base class for all standalone, "normal" terminal windows. This sets the basic
+/// style and configuration of the window based on the app configuration.
+class TerminalWindow: NSWindow {
+ /// This is the key in UserDefaults to use for the default `level` value. This is
+ /// used by the manual float on top menu item feature.
+ static let defaultLevelKey: String = "TerminalDefaultLevel"
+
+ /// The view model for SwiftUI views
+ private var viewModel = ViewModel()
+
+ /// Reset split zoom button in titlebar
+ private let resetZoomAccessory = NSTitlebarAccessoryViewController()
+
+ /// The configuration derived from the Ghostty config so we don't need to rely on references.
+ private(set) var derivedConfig: DerivedConfig = .init()
+
+ /// Gets the terminal controller from the window controller.
+ var terminalController: TerminalController? {
+ windowController as? TerminalController
+ }
+
+ // MARK: NSWindow Overrides
+
+ override var toolbar: NSToolbar? {
+ didSet {
+ DispatchQueue.main.async {
+ // When we have a toolbar, our SwiftUI view needs to know for layout
+ self.viewModel.hasToolbar = self.toolbar != nil
+ }
+ }
+ }
+
+ override func awakeFromNib() {
+ guard let appDelegate = NSApp.delegate as? AppDelegate else { return }
+
+ // All new windows are based on the app config at the time of creation.
+ let config = appDelegate.ghostty.config
+
+ // Setup our initial config
+ derivedConfig = .init(config)
+
+ // If window decorations are disabled, remove our title
+ if (!config.windowDecorations) { styleMask.remove(.titled) }
+
+ // Set our window positioning to coordinates if config value exists, otherwise
+ // fallback to original centering behavior
+ setInitialWindowPosition(
+ x: config.windowPositionX,
+ y: config.windowPositionY,
+ windowDecorations: config.windowDecorations)
+
+ // If our traffic buttons should be hidden, then hide them
+ if config.macosWindowButtons == .hidden {
+ hideWindowButtons()
+ }
+
+ // Create our reset zoom titlebar accessory. We have to have a title
+ // to do this or AppKit triggers an assertion.
+ if styleMask.contains(.titled) {
+ resetZoomAccessory.layoutAttribute = .right
+ resetZoomAccessory.view = NSHostingView(rootView: ResetZoomAccessoryView(
+ viewModel: viewModel,
+ action: { [weak self] in
+ guard let self else { return }
+ self.terminalController?.splitZoom(self)
+ }))
+ addTitlebarAccessoryViewController(resetZoomAccessory)
+ resetZoomAccessory.view.translatesAutoresizingMaskIntoConstraints = false
+ }
+
+ // Setup the accessory view for tabs that shows our keyboard shortcuts,
+ // zoomed state, etc. Note I tried to use SwiftUI here but ran into issues
+ // where buttons were not clickable.
+ let stackView = NSStackView(views: [keyEquivalentLabel, resetZoomTabButton])
+ stackView.setHuggingPriority(.defaultHigh, for: .horizontal)
+ stackView.spacing = 3
+ tab.accessoryView = stackView
+
+ // Get our saved level
+ level = UserDefaults.standard.value(forKey: Self.defaultLevelKey) as? NSWindow.Level ?? .normal
+ }
+
+ // Both of these must be true for windows without decorations to be able to
+ // still become key/main and receive events.
+ override var canBecomeKey: Bool { return true }
+ override var canBecomeMain: Bool { return true }
+
+ override func becomeKey() {
+ super.becomeKey()
+ resetZoomTabButton.contentTintColor = .controlAccentColor
+ }
+
+ override func resignKey() {
+ super.resignKey()
+ resetZoomTabButton.contentTintColor = .secondaryLabelColor
+ }
+
+ override func becomeMain() {
+ super.becomeMain()
+
+ // Its possible we miss the accessory titlebar call so we check again
+ // whenever the window becomes main. Both of these are idempotent.
+ if hasTabBar {
+ tabBarDidAppear()
+ } else {
+ tabBarDidDisappear()
+ }
+ }
+
+ override func mergeAllWindows(_ sender: Any?) {
+ super.mergeAllWindows(sender)
+
+ // It takes an event loop cycle to merge all the windows so we set a
+ // short timer to relabel the tabs (issue #1902)
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
+ self?.terminalController?.relabelTabs()
+ }
+ }
+
+ override func addTitlebarAccessoryViewController(_ childViewController: NSTitlebarAccessoryViewController) {
+ super.addTitlebarAccessoryViewController(childViewController)
+
+ // Tab bar is attached as a titlebar accessory view controller (layout bottom). We
+ // can detect when it is shown or hidden by overriding add/remove and searching for
+ // it. This has been verified to work on macOS 12 to 26
+ if isTabBar(childViewController) {
+ childViewController.identifier = Self.tabBarIdentifier
+ tabBarDidAppear()
+ }
+ }
+
+ override func removeTitlebarAccessoryViewController(at index: Int) {
+ if let childViewController = titlebarAccessoryViewControllers[safe: index], isTabBar(childViewController) {
+ tabBarDidDisappear()
+ }
+
+ super.removeTitlebarAccessoryViewController(at: index)
+ }
+
+ // MARK: Tab Bar
+
+ /// This identifier is attached to the tab bar view controller when we detect it being
+ /// added.
+ static let tabBarIdentifier: NSUserInterfaceItemIdentifier = .init("_ghosttyTabBar")
+
+ /// Returns true if there is a tab bar visible on this window.
+ var hasTabBar: Bool {
+ contentView?.firstViewFromRoot(withClassName: "NSTabBar") != nil
+ }
+
+ func isTabBar(_ childViewController: NSTitlebarAccessoryViewController) -> Bool {
+ if childViewController.identifier == nil {
+ // The good case
+ if childViewController.view.contains(className: "NSTabBar") {
+ return true
+ }
+
+ // When a new window is attached to an existing tab group, AppKit adds
+ // an empty NSView as an accessory view and adds the tab bar later. If
+ // we're at the bottom and are a single NSView we assume its a tab bar.
+ if childViewController.layoutAttribute == .bottom &&
+ childViewController.view.className == "NSView" &&
+ childViewController.view.subviews.isEmpty {
+ return true
+ }
+
+ return false
+ }
+
+ // View controllers should be tagged with this as soon as possible to
+ // increase our accuracy. We do this manually.
+ return childViewController.identifier == Self.tabBarIdentifier
+ }
+
+ private func tabBarDidAppear() {
+ // Remove our reset zoom accessory. For some reason having a SwiftUI
+ // titlebar accessory causes our content view scaling to be wrong.
+ // Removing it fixes it, we just need to remember to add it again later.
+ if let idx = titlebarAccessoryViewControllers.firstIndex(of: resetZoomAccessory) {
+ removeTitlebarAccessoryViewController(at: idx)
+ }
+ }
+
+ private func tabBarDidDisappear() {
+ if styleMask.contains(.titled) {
+ if titlebarAccessoryViewControllers.firstIndex(of: resetZoomAccessory) == nil {
+ addTitlebarAccessoryViewController(resetZoomAccessory)
+ }
+ }
+ }
+
+ // MARK: Tab Key Equivalents
+
+ var keyEquivalent: String? = nil {
+ didSet {
+ // When our key equivalent is set, we must update the tab label.
+ guard let keyEquivalent else {
+ keyEquivalentLabel.attributedStringValue = NSAttributedString()
+ return
+ }
+
+ keyEquivalentLabel.attributedStringValue = NSAttributedString(
+ string: "\(keyEquivalent) ",
+ attributes: [
+ .font: NSFont.systemFont(ofSize: NSFont.smallSystemFontSize),
+ .foregroundColor: isKeyWindow ? NSColor.labelColor : NSColor.secondaryLabelColor,
+ ])
+ }
+ }
+
+ /// The label that has the key equivalent for tab views.
+ private lazy var keyEquivalentLabel: NSTextField = {
+ let label = NSTextField(labelWithAttributedString: NSAttributedString())
+ label.setContentCompressionResistancePriority(.windowSizeStayPut, for: .horizontal)
+ label.postsFrameChangedNotifications = true
+ return label
+ }()
+
+ // MARK: Surface Zoom
+
+ /// Set to true if a surface is currently zoomed to show the reset zoom button.
+ var surfaceIsZoomed: Bool = false {
+ didSet {
+ // Show/hide our reset zoom button depending on if we're zoomed.
+ // We want to show it if we are zoomed.
+ resetZoomTabButton.isHidden = !surfaceIsZoomed
+
+ DispatchQueue.main.async {
+ self.viewModel.isSurfaceZoomed = self.surfaceIsZoomed
+ }
+ }
+ }
+
+ private lazy var resetZoomTabButton: NSButton = generateResetZoomButton()
+
+ private func generateResetZoomButton() -> NSButton {
+ let button = NSButton()
+ button.isHidden = true
+ button.target = terminalController
+ button.action = #selector(TerminalController.splitZoom(_:))
+ button.isBordered = false
+ button.allowsExpansionToolTips = true
+ button.toolTip = "Reset Zoom"
+ button.contentTintColor = .controlAccentColor
+ button.state = .on
+ button.image = NSImage(named:"ResetZoom")
+ button.frame = NSRect(x: 0, y: 0, width: 20, height: 20)
+ button.translatesAutoresizingMaskIntoConstraints = false
+ button.widthAnchor.constraint(equalToConstant: 20).isActive = true
+ button.heightAnchor.constraint(equalToConstant: 20).isActive = true
+ return button
+ }
+
+ // MARK: Title Text
+
+ override var title: String {
+ didSet {
+ // Whenever we change the window title we must also update our
+ // tab title if we're using custom fonts.
+ tab.attributedTitle = attributedTitle
+ }
+ }
+
+ // Used to set the titlebar font.
+ var titlebarFont: NSFont? {
+ didSet {
+ let font = titlebarFont ?? NSFont.titleBarFont(ofSize: NSFont.systemFontSize)
+
+ titlebarTextField?.font = font
+ tab.attributedTitle = attributedTitle
+ }
+ }
+
+ // Find the NSTextField responsible for displaying the titlebar's title.
+ private var titlebarTextField: NSTextField? {
+ titlebarContainer?
+ .firstDescendant(withClassName: "NSTitlebarView")?
+ .firstDescendant(withClassName: "NSTextField") as? NSTextField
+ }
+
+ // Return a styled representation of our title property.
+ var attributedTitle: NSAttributedString? {
+ guard let titlebarFont = titlebarFont else { return nil }
+
+ let attributes: [NSAttributedString.Key: Any] = [
+ .font: titlebarFont,
+ .foregroundColor: isKeyWindow ? NSColor.labelColor : NSColor.secondaryLabelColor,
+ ]
+ return NSAttributedString(string: title, attributes: attributes)
+ }
+
+ var titlebarContainer: NSView? {
+ // If we aren't fullscreen then the titlebar container is part of our window.
+ if !styleMask.contains(.fullScreen) {
+ return contentView?.firstViewFromRoot(withClassName: "NSTitlebarContainerView")
+ }
+
+ // If we are fullscreen, the titlebar container view is part of a separate
+ // "fullscreen window", we need to find the window and then get the view.
+ for window in NSApplication.shared.windows {
+ // This is the private window class that contains the toolbar
+ guard window.className == "NSToolbarFullScreenWindow" else { continue }
+
+ // The parent will match our window. This is used to filter the correct
+ // fullscreen window if we have multiple.
+ guard window.parent == self else { continue }
+
+ return window.contentView?.firstViewFromRoot(withClassName: "NSTitlebarContainerView")
+ }
+
+ return nil
+ }
+
+ // MARK: Positioning And Styling
+
+ /// This is called by the controller when there is a need to reset the window appearance.
+ func syncAppearance(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) {
+ // If our window is not visible, then we do nothing. Some things such as blurring
+ // have no effect if the window is not visible. Ultimately, we'll have this called
+ // at some point when a surface becomes focused.
+ guard isVisible else { return }
+
+ // Basic properties
+ appearance = surfaceConfig.windowAppearance
+ hasShadow = surfaceConfig.macosWindowShadow
+
+ // Window transparency only takes effect if our window is not native fullscreen.
+ // In native fullscreen we disable transparency/opacity because the background
+ // becomes gray and widgets show through.
+ if !styleMask.contains(.fullScreen) &&
+ surfaceConfig.backgroundOpacity < 1
+ {
+ isOpaque = false
+
+ // This is weird, but we don't use ".clear" because this creates a look that
+ // matches Terminal.app much more closer. This lets users transition from
+ // Terminal.app more easily.
+ backgroundColor = .white.withAlphaComponent(0.001)
+
+ if let appDelegate = NSApp.delegate as? AppDelegate {
+ ghostty_set_window_background_blur(
+ appDelegate.ghostty.app,
+ Unmanaged.passUnretained(self).toOpaque())
+ }
+ } else {
+ isOpaque = true
+
+ let backgroundColor = preferredBackgroundColor ?? NSColor(surfaceConfig.backgroundColor)
+ self.backgroundColor = backgroundColor.withAlphaComponent(1)
+ }
+ }
+
+ /// The preferred window background color. The current window background color may not be set
+ /// to this, since this is dynamic based on the state of the surface tree.
+ ///
+ /// This background color will include alpha transparency if set. If the caller doesn't want that,
+ /// change the alpha channel again manually.
+ var preferredBackgroundColor: NSColor? {
+ if let terminalController, !terminalController.surfaceTree.isEmpty {
+ let surface: Ghostty.SurfaceView?
+
+ // If our focused surface borders the top then we prefer its background color
+ if let focusedSurface = terminalController.focusedSurface,
+ let treeRoot = terminalController.surfaceTree.root,
+ let focusedNode = treeRoot.node(view: focusedSurface),
+ treeRoot.spatial().doesBorder(side: .up, from: focusedNode) {
+ surface = focusedSurface
+ } else {
+ // If it doesn't border the top, we use the top-left leaf
+ surface = terminalController.surfaceTree.root?.leftmostLeaf()
+ }
+
+ if let surface {
+ let backgroundColor = surface.backgroundColor ?? surface.derivedConfig.backgroundColor
+ let alpha = surface.derivedConfig.backgroundOpacity.clamped(to: 0.001...1)
+ return NSColor(backgroundColor).withAlphaComponent(alpha)
+ }
+ }
+
+ let alpha = derivedConfig.backgroundOpacity.clamped(to: 0.001...1)
+ return derivedConfig.backgroundColor.withAlphaComponent(alpha)
+ }
+
+ private func setInitialWindowPosition(x: Int16?, y: Int16?, windowDecorations: Bool) {
+ // If we don't have an X/Y then we try to use the previously saved window pos.
+ guard let x, let y else {
+ if (!LastWindowPosition.shared.restore(self)) {
+ center()
+ }
+
+ return
+ }
+
+ // Prefer the screen our window is being placed on otherwise our primary screen.
+ guard let screen = screen ?? NSScreen.screens.first else {
+ center()
+ return
+ }
+
+ // Orient based on the top left of the primary monitor
+ let frame = screen.visibleFrame
+ setFrameOrigin(.init(
+ x: frame.minX + CGFloat(x),
+ y: frame.maxY - (CGFloat(y) + frame.height)))
+ }
+
+ private func hideWindowButtons() {
+ standardWindowButton(.closeButton)?.isHidden = true
+ standardWindowButton(.miniaturizeButton)?.isHidden = true
+ standardWindowButton(.zoomButton)?.isHidden = true
+ }
+
+ // MARK: Config
+
+ struct DerivedConfig {
+ let backgroundColor: NSColor
+ let backgroundOpacity: Double
+ let macosWindowButtons: Ghostty.MacOSWindowButtons
+
+ init() {
+ self.backgroundColor = NSColor.windowBackgroundColor
+ self.backgroundOpacity = 1
+ self.macosWindowButtons = .visible
+ }
+
+ init(_ config: Ghostty.Config) {
+ self.backgroundColor = NSColor(config.backgroundColor)
+ self.backgroundOpacity = config.backgroundOpacity
+ self.macosWindowButtons = config.macosWindowButtons
+ }
+ }
+}
+
+// MARK: SwiftUI View
+
+extension TerminalWindow {
+ class ViewModel: ObservableObject {
+ @Published var isSurfaceZoomed: Bool = false
+ @Published var hasToolbar: Bool = false
+ }
+
+ struct ResetZoomAccessoryView: View {
+ @ObservedObject var viewModel: ViewModel
+ let action: () -> Void
+
+ // The padding from the top that the view appears. This was all just manually
+ // measured based on the OS.
+ var topPadding: CGFloat {
+ if #available(macOS 26.0, *) {
+ return viewModel.hasToolbar ? 10 : 5
+ } else {
+ return viewModel.hasToolbar ? 9 : 4
+ }
+ }
+
+ var body: some View {
+ if viewModel.isSurfaceZoomed {
+ VStack {
+ Button(action: action) {
+ Image("ResetZoom")
+ .foregroundColor(.accentColor)
+ }
+ .buttonStyle(.plain)
+ .help("Reset Split Zoom")
+ .frame(width: 20, height: 20)
+ Spacer()
+ }
+ // With a toolbar, the window title is taller, so we need more padding
+ // to properly align.
+ .padding(.top, topPadding)
+ // We always need space at the end of the titlebar
+ .padding(.trailing, 10)
+ }
+ }
+ }
+}
diff --git a/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift
new file mode 100644
index 000000000..9381f7329
--- /dev/null
+++ b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift
@@ -0,0 +1,262 @@
+import AppKit
+import SwiftUI
+
+/// `macos-titlebar-style = tabs` for macOS 26 (Tahoe) and later.
+///
+/// This inherits from transparent styling so that the titlebar matches the background color
+/// of the window.
+class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSToolbarDelegate {
+ /// The view model for SwiftUI views
+ private var viewModel = ViewModel()
+
+ deinit {
+ tabBarObserver = nil
+ }
+
+ // MARK: NSWindow
+
+ override var title: String {
+ didSet {
+ viewModel.title = title
+ }
+ }
+
+ override func awakeFromNib() {
+ super.awakeFromNib()
+
+ // We must hide the title since we're going to be moving tabs into
+ // the titlebar which have their own title.
+ titleVisibility = .hidden
+
+ // Create a toolbar
+ let toolbar = NSToolbar(identifier: "TerminalToolbar")
+ toolbar.delegate = self
+ toolbar.centeredItemIdentifiers.insert(.title)
+ self.toolbar = toolbar
+ toolbarStyle = .unifiedCompact
+ }
+
+ override func becomeMain() {
+ super.becomeMain()
+
+ // Check if we have a tab bar and set it up if we have to. See the comment
+ // on this function to learn why we need to check this here.
+ setupTabBar()
+ }
+
+ // This is called by macOS for native tabbing in order to add the tab bar. We hook into
+ // this, detect the tab bar being added, and override its behavior.
+ override func addTitlebarAccessoryViewController(_ childViewController: NSTitlebarAccessoryViewController) {
+ // If this is the tab bar then we need to set it up for the titlebar
+ guard isTabBar(childViewController) else {
+ super.addTitlebarAccessoryViewController(childViewController)
+ return
+ }
+
+ // Some setup needs to happen BEFORE it is added, such as layout. If
+ // we don't do this before the call below, we'll trigger an AppKit
+ // assertion.
+ childViewController.layoutAttribute = .right
+
+ super.addTitlebarAccessoryViewController(childViewController)
+
+ // Setup the tab bar to go into the titlebar.
+ DispatchQueue.main.async {
+ // HACK: wait a tick before doing anything, to avoid edge cases during startup... :/
+ // If we don't do this then on launch windows with restored state with tabs will end
+ // up with messed up tab bars that don't show all tabs.
+ self.setupTabBar()
+ }
+ }
+
+ override func removeTitlebarAccessoryViewController(at index: Int) {
+ guard let childViewController = titlebarAccessoryViewControllers[safe: index],
+ isTabBar(childViewController) else {
+ super.removeTitlebarAccessoryViewController(at: index)
+ return
+ }
+
+ super.removeTitlebarAccessoryViewController(at: index)
+
+ removeTabBar()
+ }
+
+ // MARK: Tab Bar Setup
+
+ private var tabBarObserver: NSObjectProtocol? {
+ didSet {
+ // When we change this we want to clear our old observer
+ guard let oldValue else { return }
+ NotificationCenter.default.removeObserver(oldValue)
+ }
+ }
+
+ /// Take the NSTabBar that is on the window and convert it into titlebar tabs.
+ ///
+ /// Let me explain more background on what is happening here. When a tab bar is created, only the
+ /// main window actually has an NSTabBar. When an NSWindow in the tab group gains main, AppKit
+ /// creates/moves (unsure which) the NSTabBar for it and shows it. When it loses main, the tab bar
+ /// is removed from the view hierarchy.
+ ///
+ /// We can't reliably detect this via `addTitlebarAccessoryViewController` because AppKit
+ /// creates an accessory view controller for every window in the tab group, but only attaches
+ /// the actual NSTabBar to the main window's accessory view.
+ ///
+ /// The best way I've found to detect this is to search for and setup the tab bar anytime the
+ /// window gains focus. There are probably edge cases to check but to resolve all this I made
+ /// this function which is idempotent to call.
+ ///
+ /// There are more scenarios to look out for and they're documented within the method.
+ func setupTabBar() {
+ // We only want to setup the observer once
+ guard tabBarObserver == nil else { return }
+
+ // Find our tab bar. If it doesn't exist we don't do anything.
+ guard let tabBar = contentView?.rootView.firstDescendant(withClassName: "NSTabBar") else { return }
+
+ // View model updates must happen on their own ticks.
+ DispatchQueue.main.async {
+ self.viewModel.hasTabBar = true
+ }
+
+ // Find our clip view
+ guard let clipView = tabBar.firstSuperview(withClassName: "NSTitlebarAccessoryClipView") else { return }
+ guard let accessoryView = clipView.subviews[safe: 0] else { return }
+ guard let titlebarView = clipView.firstSuperview(withClassName: "NSTitlebarView") else { return }
+ guard let toolbarView = titlebarView.firstDescendant(withClassName: "NSToolbarView") else { return }
+
+ // The container is the view that we'll constrain our tab bar within.
+ let container = toolbarView
+
+ // The padding for the tab bar. If we're showing window buttons then
+ // we need to offset the window buttons.
+ let leftPadding: CGFloat = switch(self.derivedConfig.macosWindowButtons) {
+ case .hidden: 0
+ case .visible: 70
+ }
+
+ // Constrain the accessory clip view (the parent of the accessory view
+ // usually that clips the children) to the container view.
+ clipView.translatesAutoresizingMaskIntoConstraints = false
+ accessoryView.translatesAutoresizingMaskIntoConstraints = false
+
+ // Setup all our constraints
+ NSLayoutConstraint.activate([
+ clipView.leftAnchor.constraint(equalTo: container.leftAnchor, constant: leftPadding),
+ clipView.rightAnchor.constraint(equalTo: container.rightAnchor),
+ clipView.topAnchor.constraint(equalTo: container.topAnchor, constant: 2),
+ clipView.heightAnchor.constraint(equalTo: container.heightAnchor),
+ accessoryView.leftAnchor.constraint(equalTo: clipView.leftAnchor),
+ accessoryView.rightAnchor.constraint(equalTo: clipView.rightAnchor),
+ accessoryView.topAnchor.constraint(equalTo: clipView.topAnchor),
+ accessoryView.heightAnchor.constraint(equalTo: clipView.heightAnchor),
+ ])
+
+ clipView.needsLayout = true
+ accessoryView.needsLayout = true
+
+ // Setup an observer for the NSTabBar frame. When system appearance changes or
+ // other events occur, the tab bar can temporarily become zero-sized. When this
+ // happens, we need to remove our custom constraints and re-apply them once the
+ // tab bar has proper dimensions again to avoid constraint conflicts.
+ tabBar.postsFrameChangedNotifications = true
+ tabBarObserver = NotificationCenter.default.addObserver(
+ forName: NSView.frameDidChangeNotification,
+ object: tabBar,
+ queue: .main
+ ) { [weak self] _ in
+ guard let self else { return }
+
+ // Check if either width or height is zero
+ guard tabBar.frame.size.width == 0 || tabBar.frame.size.height == 0 else { return }
+
+ // Remove the observer so we can call setup again.
+ self.tabBarObserver = nil
+
+ // Wait a tick to let the new tab bars appear and then set them up.
+ DispatchQueue.main.async {
+ self.setupTabBar()
+ }
+ }
+ }
+
+ func removeTabBar() {
+ // View model needs to be updated on another tick because it
+ // triggers view updates.
+ DispatchQueue.main.async {
+ self.viewModel.hasTabBar = false
+ }
+
+ // Clear our observations
+ self.tabBarObserver = nil
+ }
+
+ // MARK: NSToolbarDelegate
+
+ func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
+ return [.title, .flexibleSpace, .space]
+ }
+
+ func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
+ return [.flexibleSpace, .title, .flexibleSpace]
+ }
+
+ func toolbar(_ toolbar: NSToolbar,
+ itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier,
+ willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem? {
+ switch itemIdentifier {
+ case .title:
+ let item = NSToolbarItem(itemIdentifier: .title)
+ item.view = NSHostingView(rootView: TitleItem(viewModel: viewModel))
+ item.visibilityPriority = .user
+ item.isEnabled = true
+
+ // This is the documented way to avoid the glass view on an item.
+ // We don't want glass on our title.
+ item.isBordered = false
+
+ return item
+ default:
+ return NSToolbarItem(itemIdentifier: itemIdentifier)
+ }
+ }
+
+ // MARK: SwiftUI
+
+ class ViewModel: ObservableObject {
+ @Published var title: String = "👻 Ghostty"
+ @Published var hasTabBar: Bool = false
+ }
+}
+
+extension NSToolbarItem.Identifier {
+ /// Displays the title of the window
+ static let title = NSToolbarItem.Identifier("Title")
+}
+
+extension TitlebarTabsTahoeTerminalWindow {
+ /// Displays the window title
+ struct TitleItem: View {
+ @ObservedObject var viewModel: ViewModel
+
+ var title: String {
+ // An empty title makes this view zero-sized and NSToolbar on macOS
+ // tahoe just deletes the item when that happens. So we use a space
+ // instead to ensure there's always some size.
+ return viewModel.title.isEmpty ? " " : viewModel.title
+ }
+
+ var body: some View {
+ if !viewModel.hasTabBar {
+ Text(title)
+ .lineLimit(1)
+ .truncationMode(.tail)
+ } else {
+ // 1x1.gif strikes again! For real: if we render a zero-sized
+ // view here then the toolbar just disappears our view. I don't
+ // know.
+ Color.clear.frame(width: 1, height: 1)
+ }
+ }
+ }
+}
diff --git a/macos/Sources/Features/Terminal/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift
index 62b8dc5bf..99111b55b 100644
--- a/macos/Sources/Features/Terminal/TerminalWindow.swift
+++ b/macos/Sources/Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift
@@ -1,14 +1,10 @@
import Cocoa
-class TerminalWindow: NSWindow {
- /// This is the key in UserDefaults to use for the default `level` value.
- static let defaultLevelKey: String = "TerminalDefaultLevel"
-
- @objc dynamic var keyEquivalent: String = ""
-
+/// Titlebar tabs for macOS 13 to 15.
+class TitlebarTabsVenturaTerminalWindow: TerminalWindow {
/// This is used to determine if certain elements should be drawn light or dark and should
/// be updated whenever the window background color or surrounding elements changes.
- var isLightTheme: Bool = false
+ fileprivate var isLightTheme: Bool = false
lazy var titlebarColor: NSColor = backgroundColor {
didSet {
@@ -18,131 +14,39 @@ class TerminalWindow: NSWindow {
}
}
- private lazy var keyEquivalentLabel: NSTextField = {
- let label = NSTextField(labelWithAttributedString: NSAttributedString())
- label.setContentCompressionResistancePriority(.windowSizeStayPut, for: .horizontal)
- label.postsFrameChangedNotifications = true
-
- return label
- }()
-
- private lazy var bindings = [
- observe(\.surfaceIsZoomed, options: [.initial, .new]) { [weak self] window, _ in
- guard let tabGroup = self?.tabGroup else { return }
-
- self?.resetZoomTabButton.isHidden = !window.surfaceIsZoomed
- self?.updateResetZoomTitlebarButtonVisibility()
- },
-
- observe(\.keyEquivalent, options: [.initial, .new]) { [weak self] window, _ in
- let attributes: [NSAttributedString.Key: Any] = [
- .font: NSFont.systemFont(ofSize: NSFont.smallSystemFontSize),
- .foregroundColor: window.isKeyWindow ? NSColor.labelColor : NSColor.secondaryLabelColor,
- ]
- let attributedString = NSAttributedString(string: " \(window.keyEquivalent) ", attributes: attributes)
-
- self?.keyEquivalentLabel.attributedStringValue = attributedString
- },
- ]
-
- // Both of these must be true for windows without decorations to be able to
- // still become key/main and receive events.
- override var canBecomeKey: Bool { return true }
- override var canBecomeMain: Bool { return true }
+ // false if all three traffic lights are missing/hidden, otherwise true
+ private var hasWindowButtons: Bool {
+ get {
+ // if standardWindowButton(.theButton) == nil, the button isn't there, so coalesce to true
+ let closeIsHidden = standardWindowButton(.closeButton)?.isHiddenOrHasHiddenAncestor ?? true
+ let miniaturizeIsHidden = standardWindowButton(.miniaturizeButton)?.isHiddenOrHasHiddenAncestor ?? true
+ let zoomIsHidden = standardWindowButton(.zoomButton)?.isHiddenOrHasHiddenAncestor ?? true
+ return !(closeIsHidden && miniaturizeIsHidden && zoomIsHidden)
+ }
+ }
- // MARK: - Lifecycle
+ // MARK: NSWindow
override func awakeFromNib() {
super.awakeFromNib()
- _ = bindings
-
- // Create the tab accessory view that houses the key-equivalent label and optional un-zoom button
- let stackView = NSStackView(views: [keyEquivalentLabel, resetZoomTabButton])
- stackView.setHuggingPriority(.defaultHigh, for: .horizontal)
- stackView.spacing = 3
- tab.accessoryView = stackView
-
- if titlebarTabs {
- generateToolbar()
- }
-
- level = UserDefaults.standard.value(forKey: Self.defaultLevelKey) as? NSWindow.Level ?? .normal
- }
-
- deinit {
- bindings.forEach() { $0.invalidate() }
- }
-
- // MARK: Titlebar Helpers
- // These helpers are generic to what we're trying to achieve (i.e. titlebar
- // style tabs, titlebar styling, etc.). They're just here to make it easier.
-
- private var titlebarContainer: NSView? {
- // If we aren't fullscreen then the titlebar container is part of our window.
- if !styleMask.contains(.fullScreen) {
- guard let view = contentView?.superview ?? contentView else { return nil }
- return titlebarContainerView(in: view)
- }
-
- // If we are fullscreen, the titlebar container view is part of a separate
- // "fullscreen window", we need to find the window and then get the view.
- for window in NSApplication.shared.windows {
- // This is the private window class that contains the toolbar
- guard window.className == "NSToolbarFullScreenWindow" else { continue }
-
- // The parent will match our window. This is used to filter the correct
- // fullscreen window if we have multiple.
- guard window.parent == self else { continue }
-
- guard let view = window.contentView else { continue }
- return titlebarContainerView(in: view)
+ // Handle titlebar tabs config option. Something about what we do while setting up the
+ // titlebar tabs interferes with the window restore process unless window.tabbingMode
+ // is set to .preferred, so we set it, and switch back to automatic as soon as we can.
+ tabbingMode = .preferred
+ DispatchQueue.main.async {
+ self.tabbingMode = .automatic
}
- return nil
- }
-
- private func titlebarContainerView(in view: NSView) -> NSView? {
- if view.className == "NSTitlebarContainerView" {
- return view
- }
+ titlebarTabs = true
- for subview in view.subviews {
- if let found = titlebarContainerView(in: subview) {
- return found
- }
- }
+ // Set the background color of the window
+ backgroundColor = derivedConfig.backgroundColor
- return nil
+ // This makes sure our titlebar renders correctly when there is a transparent background
+ titlebarColor = derivedConfig.backgroundColor.withAlphaComponent(derivedConfig.backgroundOpacity)
}
- // MARK: - NSWindow
-
- override var title: String {
- didSet {
- tab.attributedTitle = attributedTitle
- }
- }
-
- // We override this so that with the hidden titlebar style the titlebar
- // area is not draggable.
- override var contentLayoutRect: CGRect {
- var rect = super.contentLayoutRect
-
- // If we are using a hidden titlebar style, the content layout is the
- // full frame making it so that it is not draggable.
- if let controller = windowController as? TerminalController,
- controller.derivedConfig.macosTitlebarStyle == "hidden" {
- rect.origin.y = 0
- rect.size.height = self.frame.height
- }
- return rect
- }
-
- // The window theme configuration from Ghostty. This is used to control some
- // behaviors that don't look quite right in certain situations.
- var windowTheme: TerminalWindowTheme?
-
// We only need to set this once, but need to do it after the window has been created in order
// to determine if the theme is using a very dark background, in which case we don't want to
// remove the effect view if the default tab bar is being used since the effect created in
@@ -153,13 +57,12 @@ class TerminalWindow: NSWindow {
// This is required because the removeTitlebarAccessoryViewController hook does not
// catch the creation of a new window by "tearing off" a tab from a tabbed window.
if let tabGroup = self.tabGroup, tabGroup.windows.count < 2 {
- hideCustomTabBarViews()
+ resetCustomTabBarViews()
}
super.becomeKey()
updateNewTabButtonOpacity()
- resetZoomTabButton.contentTintColor = .controlAccentColor
resetZoomToolbarButton.contentTintColor = .controlAccentColor
tab.attributedTitle = attributedTitle
}
@@ -168,7 +71,6 @@ class TerminalWindow: NSWindow {
super.resignKey()
updateNewTabButtonOpacity()
- resetZoomTabButton.contentTintColor = .secondaryLabelColor
resetZoomToolbarButton.contentTintColor = .tertiaryLabelColor
tab.attributedTitle = attributedTitle
}
@@ -197,11 +99,6 @@ class TerminalWindow: NSWindow {
}
}
- updateResetZoomTitlebarButtonVisibility()
-
- // The remainder of this function only applies to styled tabs.
- guard hasStyledTabs else { return }
-
titlebarSeparatorStyle = tabbedWindows != nil && !titlebarTabs ? .line : .none
if titlebarTabs {
hideToolbarOverflowButton()
@@ -246,20 +143,29 @@ class TerminalWindow: NSWindow {
}
}
- // MARK: - Tab Bar Styling
+ // MARK: Appearance
+
+ override func syncAppearance(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) {
+ super.syncAppearance(surfaceConfig)
- // This is true if we should apply styles to the titlebar or tab bar.
- var hasStyledTabs: Bool {
- // If we have titlebar tabs then we always style.
- guard !titlebarTabs else { return true }
+ // Update our window light/darkness based on our updated background color
+ isLightTheme = OSColor(surfaceConfig.backgroundColor).isLightColor
+
+ // Update our titlebar color
+ if let preferredBackgroundColor {
+ titlebarColor = preferredBackgroundColor
+ } else {
+ titlebarColor = derivedConfig.backgroundColor.withAlphaComponent(derivedConfig.backgroundOpacity)
+ }
- // We style the tabs if they're transparent
- return transparentTabs
+ if (isOpaque) {
+ // If there is transparency, calling this will make the titlebar opaque
+ // so we only call this if we are opaque.
+ updateTabBar()
+ }
}
- // Set to true if the background color should bleed through the titlebar/tab bar.
- // This only applies to non-titlebar tabs.
- var transparentTabs: Bool = false
+ // MARK: Tab Bar Styling
var hasVeryDarkBackground: Bool {
backgroundColor.luminance < 0.05
@@ -274,8 +180,7 @@ class TerminalWindow: NSWindow {
// We can only update titlebar tabs if there is a titlebar. Without the
// styleMask check the app will crash (issue #1876)
if titlebarTabs && styleMask.contains(.titled) {
- guard let tabBarAccessoryViewController = titlebarAccessoryViewControllers.first(where: { $0.identifier == Self.TabBarController}) else { return }
-
+ guard let tabBarAccessoryViewController = titlebarAccessoryViewControllers.first(where: { $0.identifier == Self.tabBarIdentifier}) else { return }
tabBarAccessoryViewController.layoutAttribute = .right
pushTabsToTitlebar(tabBarAccessoryViewController)
}
@@ -342,53 +247,8 @@ class TerminalWindow: NSWindow {
// MARK: - Split Zoom Button
- @objc dynamic var surfaceIsZoomed: Bool = false
-
private lazy var resetZoomToolbarButton: NSButton = generateResetZoomButton()
- private lazy var resetZoomTabButton: NSButton = {
- let button = generateResetZoomButton()
- button.action = #selector(selectTabAndZoom(_:))
- return button
- }()
-
- private lazy var resetZoomTitlebarAccessoryViewController: NSTitlebarAccessoryViewController? = {
- guard let titlebarContainer else { return nil }
- let size = NSSize(width: titlebarContainer.bounds.height, height: titlebarContainer.bounds.height)
- let view = NSView(frame: NSRect(origin: .zero, size: size))
-
- let button = generateResetZoomButton()
- button.frame.origin.x = size.width/2 - button.bounds.width/2
- button.frame.origin.y = size.height/2 - button.bounds.height/2
- view.addSubview(button)
-
- let titlebarAccessoryViewController = NSTitlebarAccessoryViewController()
- titlebarAccessoryViewController.view = view
- titlebarAccessoryViewController.layoutAttribute = .right
-
- return titlebarAccessoryViewController
- }()
-
- private func updateResetZoomTitlebarButtonVisibility() {
- guard let tabGroup, let resetZoomTitlebarAccessoryViewController else { return }
-
- let isHidden = tabGroup.isTabBarVisible ? true : !surfaceIsZoomed
-
- if titlebarTabs {
- resetZoomToolbarButton.isHidden = isHidden
-
- for (index, vc) in titlebarAccessoryViewControllers.enumerated() {
- guard vc == resetZoomTitlebarAccessoryViewController else { return }
- removeTitlebarAccessoryViewController(at: index)
- }
- } else {
- if !titlebarAccessoryViewControllers.contains(resetZoomTitlebarAccessoryViewController) {
- addTitlebarAccessoryViewController(resetZoomTitlebarAccessoryViewController)
- }
- resetZoomTitlebarAccessoryViewController.view.isHidden = isHidden
- }
- }
-
private func generateResetZoomButton() -> NSButton {
let button = NSButton()
button.target = nil
@@ -424,46 +284,19 @@ class TerminalWindow: NSWindow {
// MARK: - Titlebar Font
// Used to set the titlebar font.
- var titlebarFont: NSFont? {
+ override var titlebarFont: NSFont? {
didSet {
- let font = titlebarFont ?? NSFont.titleBarFont(ofSize: NSFont.systemFontSize)
-
- titlebarTextField?.font = font
- tab.attributedTitle = attributedTitle
-
- if let toolbar = toolbar as? TerminalToolbar {
- toolbar.titleFont = font
- }
+ guard let toolbar = toolbar as? TerminalToolbar else { return }
+ toolbar.titleFont = titlebarFont ?? .titleBarFont(ofSize: NSFont.systemFontSize)
}
}
- // Find the NSTextField responsible for displaying the titlebar's title.
- private var titlebarTextField: NSTextField? {
- guard let titlebarView = titlebarContainer?.subviews
- .first(where: { $0.className == "NSTitlebarView" }) else { return nil }
- return titlebarView.subviews.first(where: { $0 is NSTextField }) as? NSTextField
- }
-
- // Return a styled representation of our title property.
- private var attributedTitle: NSAttributedString? {
- guard let titlebarFont else { return nil }
-
- let attributes: [NSAttributedString.Key: Any] = [
- .font: titlebarFont,
- .foregroundColor: isKeyWindow ? NSColor.labelColor : NSColor.secondaryLabelColor,
- ]
- return NSAttributedString(string: title, attributes: attributes)
- }
-
// MARK: - Titlebar Tabs
private var windowButtonsBackdrop: WindowButtonsBackdropView? = nil
private var windowDragHandle: WindowDragView? = nil
- // The tab bar controller ID from macOS
- static private let TabBarController = NSUserInterfaceItemIdentifier("_tabBarController")
-
// Used by the window controller to enable/disable titlebar tabs.
var titlebarTabs = false {
didSet {
@@ -476,6 +309,18 @@ class TerminalWindow: NSWindow {
}
}
+ override var title: String {
+ didSet {
+ // Updating the title text as above automatically reveals the
+ // native title view in macOS 15.0 and above. Since we're using
+ // a custom view instead, we need to re-hide it.
+ titleVisibility = .hidden
+ if let toolbar = toolbar as? TerminalToolbar {
+ toolbar.titleText = title
+ }
+ }
+ }
+
// We have to regenerate a toolbar when the titlebar tabs setting changes since our
// custom toolbar conditionally generates the items based on this setting. I tried to
// invalidate the toolbar items and force a refresh, but as far as I can tell that
@@ -491,7 +336,6 @@ class TerminalWindow: NSWindow {
resetZoomItem.view!.widthAnchor.constraint(equalToConstant: 22).isActive = true
resetZoomItem.view!.heightAnchor.constraint(equalToConstant: 20).isActive = true
}
- updateResetZoomTitlebarButtonVisibility()
}
// For titlebar tabs, we want to hide the separator view so that we get rid
@@ -520,10 +364,7 @@ class TerminalWindow: NSWindow {
// This is called by macOS for native tabbing in order to add the tab bar. We hook into
// this, detect the tab bar being added, and override its behavior.
override func addTitlebarAccessoryViewController(_ childViewController: NSTitlebarAccessoryViewController) {
- let isTabBar = self.titlebarTabs && (
- childViewController.layoutAttribute == .bottom ||
- childViewController.identifier == Self.TabBarController
- )
+ let isTabBar = self.titlebarTabs && isTabBar(childViewController)
if (isTabBar) {
// Ensure it has the right layoutAttribute to force it next to our titlebar
@@ -535,7 +376,7 @@ class TerminalWindow: NSWindow {
// Mark the controller for future reference so we can easily find it. Otherwise
// the tab bar has no ID by default.
- childViewController.identifier = Self.TabBarController
+ childViewController.identifier = Self.tabBarIdentifier
}
super.addTitlebarAccessoryViewController(childViewController)
@@ -546,20 +387,25 @@ class TerminalWindow: NSWindow {
}
override func removeTitlebarAccessoryViewController(at index: Int) {
- let isTabBar = titlebarAccessoryViewControllers[index].identifier == Self.TabBarController
+ let isTabBar = titlebarAccessoryViewControllers[index].identifier == Self.tabBarIdentifier
super.removeTitlebarAccessoryViewController(at: index)
if (isTabBar) {
- hideCustomTabBarViews()
+ resetCustomTabBarViews()
}
}
// To be called immediately after the tab bar is disabled.
- private func hideCustomTabBarViews() {
+ private func resetCustomTabBarViews() {
// Hide the window buttons backdrop.
windowButtonsBackdrop?.isHidden = true
// Hide the window drag handle.
windowDragHandle?.isHidden = true
+
+ // Reenable the main toolbar title
+ if let toolbar = toolbar as? TerminalToolbar {
+ toolbar.titleIsHidden = false
+ }
}
private func pushTabsToTitlebar(_ tabBarController: NSTitlebarAccessoryViewController) {
@@ -568,6 +414,11 @@ class TerminalWindow: NSWindow {
generateToolbar()
}
+ // The main title conflicts with titlebar tabs, so hide it
+ if let toolbar = toolbar as? TerminalToolbar {
+ toolbar.titleIsHidden = true
+ }
+
// HACK: wait a tick before doing anything, to avoid edge cases during startup... :/
// If we don't do this then on launch windows with restored state with tabs will end
// up with messed up tab bars that don't show all tabs.
@@ -614,7 +465,7 @@ class TerminalWindow: NSWindow {
view.translatesAutoresizingMaskIntoConstraints = false
view.leftAnchor.constraint(equalTo: toolbarView.leftAnchor).isActive = true
- view.rightAnchor.constraint(equalTo: toolbarView.leftAnchor, constant: 78).isActive = true
+ view.rightAnchor.constraint(equalTo: toolbarView.leftAnchor, constant: hasWindowButtons ? 78 : 0).isActive = true
view.topAnchor.constraint(equalTo: toolbarView.topAnchor).isActive = true
view.heightAnchor.constraint(equalTo: toolbarView.heightAnchor).isActive = true
@@ -692,7 +543,7 @@ fileprivate class WindowDragView: NSView {
fileprivate class WindowButtonsBackdropView: NSView {
// This must be weak because the window has this view. Otherwise
// a retain cycle occurs.
- private weak var terminalWindow: TerminalWindow?
+ private weak var terminalWindow: TitlebarTabsVenturaTerminalWindow?
private let isLightTheme: Bool
private let overlayLayer = VibrantLayer()
@@ -720,7 +571,7 @@ fileprivate class WindowButtonsBackdropView: NSView {
fatalError("init(coder:) has not been implemented")
}
- init(window: TerminalWindow) {
+ init(window: TitlebarTabsVenturaTerminalWindow) {
self.terminalWindow = window
self.isLightTheme = window.isLightTheme
@@ -736,9 +587,133 @@ fileprivate class WindowButtonsBackdropView: NSView {
}
}
-enum TerminalWindowTheme: String {
- case auto
- case system
- case light
- case dark
+// MARK: Toolbar
+
+// Custom NSToolbar subclass that displays a centered window title,
+// in order to accommodate the titlebar tabs feature.
+fileprivate class TerminalToolbar: NSToolbar, NSToolbarDelegate {
+ private let titleTextField = CenteredDynamicLabel(labelWithString: "👻 Ghostty")
+
+ var titleText: String {
+ get {
+ titleTextField.stringValue
+ }
+
+ set {
+ titleTextField.stringValue = newValue
+ }
+ }
+
+ var titleFont: NSFont? {
+ get {
+ titleTextField.font
+ }
+
+ set {
+ titleTextField.font = newValue
+ }
+ }
+
+ var titleIsHidden: Bool {
+ get {
+ titleTextField.isHidden
+ }
+
+ set {
+ titleTextField.isHidden = newValue
+ }
+ }
+
+ override init(identifier: NSToolbar.Identifier) {
+ super.init(identifier: identifier)
+
+ delegate = self
+ centeredItemIdentifiers.insert(.titleText)
+ }
+
+ func toolbar(_ toolbar: NSToolbar,
+ itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier,
+ willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem? {
+ var item: NSToolbarItem
+
+ switch itemIdentifier {
+ case .titleText:
+ item = NSToolbarItem(itemIdentifier: .titleText)
+ item.view = self.titleTextField
+ item.visibilityPriority = .user
+
+ // This ensures the title text field doesn't disappear when shrinking the view
+ self.titleTextField.translatesAutoresizingMaskIntoConstraints = false
+ self.titleTextField.setContentHuggingPriority(.defaultLow, for: .horizontal)
+ self.titleTextField.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
+
+ // Add constraints to the toolbar item's view
+ NSLayoutConstraint.activate([
+ // Set the height constraint to match the toolbar's height
+ self.titleTextField.heightAnchor.constraint(equalToConstant: 22), // Adjust as needed
+ ])
+
+ item.isEnabled = true
+ case .resetZoom:
+ item = NSToolbarItem(itemIdentifier: .resetZoom)
+ default:
+ item = NSToolbarItem(itemIdentifier: itemIdentifier)
+ }
+
+ return item
+ }
+
+ func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
+ return [.titleText, .flexibleSpace, .space, .resetZoom]
+ }
+
+ func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
+ // These space items are here to ensure that the title remains centered when it starts
+ // getting smaller than the max size so starts clipping. Lucky for us, two of the
+ // built-in spacers plus the un-zoom button item seems to exactly match the space
+ // on the left that's reserved for the window buttons.
+ return [.flexibleSpace, .titleText, .flexibleSpace]
+ }
+}
+
+/// A label that expands to fit whatever text you put in it and horizontally centers itself in the current window.
+fileprivate class CenteredDynamicLabel: NSTextField {
+ override func viewDidMoveToSuperview() {
+ // Configure the text field
+ isEditable = false
+ isBordered = false
+ drawsBackground = false
+ alignment = .center
+ lineBreakMode = .byTruncatingTail
+ cell?.truncatesLastVisibleLine = true
+
+ // Use Auto Layout
+ translatesAutoresizingMaskIntoConstraints = false
+
+ // Set content hugging and compression resistance priorities
+ setContentHuggingPriority(.defaultLow, for: .horizontal)
+ setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
+ }
+
+ // Vertically center the text
+ override func draw(_ dirtyRect: NSRect) {
+ guard let attributedString = self.attributedStringValue.mutableCopy() as? NSMutableAttributedString else {
+ super.draw(dirtyRect)
+ return
+ }
+
+ let textSize = attributedString.size()
+
+ let yOffset = (self.bounds.height - textSize.height) / 2 - 1 // -1 to center it better
+
+ let centeredRect = NSRect(x: self.bounds.origin.x, y: self.bounds.origin.y + yOffset,
+ width: self.bounds.width, height: textSize.height)
+
+ attributedString.draw(in: centeredRect)
+ }
+}
+
+extension NSToolbarItem.Identifier {
+ static let resetZoom = NSToolbarItem.Identifier("ResetZoom")
+ static let titleText = NSToolbarItem.Identifier("TitleText")
}
diff --git a/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift
new file mode 100644
index 000000000..f6ad6e56c
--- /dev/null
+++ b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift
@@ -0,0 +1,198 @@
+import AppKit
+
+/// A terminal window style that provides a transparent titlebar effect. With this effect, the titlebar
+/// matches the background color of the window.
+class TransparentTitlebarTerminalWindow: TerminalWindow {
+ /// Stores the last surface configuration to reapply appearance when needed.
+ /// This is necessary because various macOS operations (tab switching, tab bar
+ /// visibility changes) can reset the titlebar appearance.
+ private var lastSurfaceConfig: Ghostty.SurfaceView.DerivedConfig?
+
+ /// KVO observation for tab group window changes.
+ private var tabGroupWindowsObservation: NSKeyValueObservation?
+ private var tabBarVisibleObservation: NSKeyValueObservation?
+
+ deinit {
+ tabGroupWindowsObservation?.invalidate()
+ tabBarVisibleObservation?.invalidate()
+ }
+
+ // MARK: NSWindow
+
+ override func awakeFromNib() {
+ super.awakeFromNib()
+
+ // Setup all the KVO we will use, see the docs for the respective functions
+ // to learn why we need KVO.
+ setupKVO()
+ }
+
+ override func becomeMain() {
+ super.becomeMain()
+
+ guard let lastSurfaceConfig else { return }
+ syncAppearance(lastSurfaceConfig)
+
+ // This is a nasty edge case. If we're going from 2 to 1 tab and the tab bar
+ // automatically disappears, then we need to resync our appearance because
+ // at some point macOS replaces the tab views.
+ if tabGroup?.windows.count ?? 0 == 2 {
+ DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(50)) { [weak self] in
+ self?.syncAppearance(self?.lastSurfaceConfig ?? lastSurfaceConfig)
+ }
+ }
+ }
+
+ override func update() {
+ super.update()
+
+ // On macOS 13 to 15, we need to hide the NSVisualEffectView in order to allow our
+ // titlebar to be truly transparent.
+ if #unavailable(macOS 26) {
+ if !effectViewIsHidden {
+ hideEffectView()
+ }
+ }
+ }
+
+ // MARK: Appearance
+
+ override func syncAppearance(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) {
+ super.syncAppearance(surfaceConfig)
+
+ // Save our config in case we need to reapply
+ lastSurfaceConfig = surfaceConfig
+
+ // Everytime we change appearance, set KVO up again in case any of our
+ // references changed (e.g. tabGroup is new).
+ setupKVO()
+
+ if #available(macOS 26.0, *) {
+ syncAppearanceTahoe(surfaceConfig)
+ } else {
+ syncAppearanceVentura(surfaceConfig)
+ }
+ }
+
+ @available(macOS 26.0, *)
+ private func syncAppearanceTahoe(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) {
+ // When we have transparency, we need to set the titlebar background to match the
+ // window background but with opacity. The window background is set using the
+ // "preferred background color" property.
+ //
+ // As an inverse, if we don't have transparency, we don't bother with this because
+ // the window background will be set to the correct color so we can just hide the
+ // titlebar completely and we're good to go.
+ if !isOpaque {
+ if let titlebarView = titlebarContainer?.firstDescendant(withClassName: "NSTitlebarView") {
+ titlebarView.wantsLayer = true
+ titlebarView.layer?.backgroundColor = preferredBackgroundColor?.cgColor
+ }
+ }
+
+ // In all cases, we have to hide the background view since this has multiple subviews
+ // that force a background color.
+ titlebarBackgroundView?.isHidden = true
+ }
+
+ @available(macOS 13.0, *)
+ private func syncAppearanceVentura(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) {
+ guard let titlebarContainer else { return }
+
+ // Setup the titlebar background color to match ours
+ titlebarContainer.wantsLayer = true
+ titlebarContainer.layer?.backgroundColor = preferredBackgroundColor?.cgColor
+
+ // See the docs for the function that sets this to true on why
+ effectViewIsHidden = false
+
+ // Necessary to not draw the border around the title
+ titlebarAppearsTransparent = true
+ }
+
+ // MARK: View Finders
+
+ private var titlebarBackgroundView: NSView? {
+ titlebarContainer?.firstDescendant(withClassName: "NSTitlebarBackgroundView")
+ }
+
+ // MARK: Tab Group Observation
+
+ private func setupKVO() {
+ // See the docs for the respective setup functions for why.
+ setupTabGroupObservation()
+ setupTabBarVisibleObservation()
+ }
+
+ /// Monitors the tabGroup windows value for any changes and resyncs the appearance on change.
+ /// This is necessary because when the windows change, the tab bar and titlebar are recreated
+ /// which breaks our changes.
+ private func setupTabGroupObservation() {
+ // Remove existing observation if any
+ tabGroupWindowsObservation?.invalidate()
+ tabGroupWindowsObservation = nil
+
+ // Check if tabGroup is available
+ guard let tabGroup else { return }
+
+ // Set up KVO observation for the windows array. Whenever it changes
+ // we resync the appearance because it can cause macOS to redraw the
+ // tab bar.
+ tabGroupWindowsObservation = tabGroup.observe(
+ \.windows,
+ options: [.new]
+ ) { [weak self] _, change in
+ // NOTE: At one point, I guarded this on only if we went from 0 to N
+ // or N to 0 under the assumption that the tab bar would only get
+ // replaced on those cases. This turned out to be false (Tahoe).
+ // It's cheap enough to always redraw this so we should just do it
+ // unconditionally.
+
+ guard let self else { return }
+ guard let lastSurfaceConfig else { return }
+ self.syncAppearance(lastSurfaceConfig)
+ }
+ }
+
+ /// Monitors the tab bar for visibility. This lets the "Show/Hide Tab Bar" manual menu item
+ /// to not break our appearance.
+ private func setupTabBarVisibleObservation() {
+ // Remove existing observation if any
+ tabBarVisibleObservation?.invalidate()
+ tabBarVisibleObservation = nil
+
+ // Set up KVO observation for isTabBarVisible
+ tabBarVisibleObservation = tabGroup?.observe(
+ \.isTabBarVisible,
+ options: [.new]
+ ) { [weak self] _, change in
+ guard let self else { return }
+ guard let lastSurfaceConfig else { return }
+ self.syncAppearance(lastSurfaceConfig)
+ }
+ }
+
+ // MARK: macOS 13 to 15
+
+ // We only need to set this once, but need to do it after the window has been created in order
+ // to determine if the theme is using a very dark background, in which case we don't want to
+ // remove the effect view if the default tab bar is being used since the effect created in
+ // `updateTabsForVeryDarkBackgrounds` creates a confusing visual design.
+ private var effectViewIsHidden = false
+
+ private func hideEffectView() {
+ guard !effectViewIsHidden else { return }
+
+ // By hiding the visual effect view, we allow the window's (or titlebar's in this case)
+ // background color to show through. If we were to set `titlebarAppearsTransparent` to true
+ // the selected tab would look fine, but the unselected ones and new tab button backgrounds
+ // would be an opaque color. When the titlebar isn't transparent, however, the system applies
+ // a compositing effect to the unselected tab backgrounds, which makes them blend with the
+ // titlebar's/window's background.
+ if let effectView = titlebarContainer?.descendants(withClassName: "NSVisualEffectView").first {
+ effectView.isHidden = true
+ }
+
+ effectViewIsHidden = true
+ }
+}
diff --git a/macos/Sources/Ghostty/AppError.swift b/macos/Sources/Ghostty/AppError.swift
deleted file mode 100644
index 55f191d3d..000000000
--- a/macos/Sources/Ghostty/AppError.swift
+++ /dev/null
@@ -1,3 +0,0 @@
-enum AppError: Error {
- case surfaceCreateError
-}
diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift
index 6736449a4..ba0b95212 100644
--- a/macos/Sources/Ghostty/Ghostty.App.swift
+++ b/macos/Sources/Ghostty/Ghostty.App.swift
@@ -553,6 +553,12 @@ extension Ghostty {
case GHOSTTY_ACTION_CHECK_FOR_UPDATES:
checkForUpdates(app)
+ case GHOSTTY_ACTION_UNDO:
+ return undo(app, target: target)
+
+ case GHOSTTY_ACTION_REDO:
+ return redo(app, target: target)
+
case GHOSTTY_ACTION_CLOSE_ALL_WINDOWS:
fallthrough
case GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW:
@@ -599,6 +605,48 @@ extension Ghostty {
}
}
+ private static func undo(_ app: ghostty_app_t, target: ghostty_target_s) -> Bool {
+ let undoManager: UndoManager?
+ switch (target.tag) {
+ case GHOSTTY_TARGET_APP:
+ undoManager = (NSApp.delegate as? AppDelegate)?.undoManager
+
+ case GHOSTTY_TARGET_SURFACE:
+ guard let surface = target.target.surface else { return false }
+ guard let surfaceView = self.surfaceView(from: surface) else { return false }
+ undoManager = surfaceView.undoManager
+
+ default:
+ assertionFailure()
+ return false
+ }
+
+ guard let undoManager, undoManager.canUndo else { return false }
+ undoManager.undo()
+ return true
+ }
+
+ private static func redo(_ app: ghostty_app_t, target: ghostty_target_s) -> Bool {
+ let undoManager: UndoManager?
+ switch (target.tag) {
+ case GHOSTTY_TARGET_APP:
+ undoManager = (NSApp.delegate as? AppDelegate)?.undoManager
+
+ case GHOSTTY_TARGET_SURFACE:
+ guard let surface = target.target.surface else { return false }
+ guard let surfaceView = self.surfaceView(from: surface) else { return false }
+ undoManager = surfaceView.undoManager
+
+ default:
+ assertionFailure()
+ return false
+ }
+
+ guard let undoManager, undoManager.canRedo else { return false }
+ undoManager.redo()
+ return true
+ }
+
private static func newWindow(_ app: ghostty_app_t, target: ghostty_target_s) {
switch (target.tag) {
case GHOSTTY_TARGET_APP:
@@ -745,7 +793,7 @@ extension Ghostty {
guard let surface = target.target.surface else { return }
guard let surfaceView = self.surfaceView(from: surface) else { return }
guard let mode = FullscreenMode.from(ghostty: raw) else {
- Ghostty.logger.warning("unknow fullscreen mode raw=\(raw.rawValue)")
+ Ghostty.logger.warning("unknown fullscreen mode raw=\(raw.rawValue)")
return
}
NotificationCenter.default.post(
@@ -921,7 +969,7 @@ extension Ghostty {
// we should only be returning true if we actually performed the action,
// but this handles the most common case of caring about goto_split performability
// which is the no-split case.
- guard controller.surfaceTree?.isSplit ?? false else { return false }
+ guard controller.surfaceTree.isSplit else { return false }
NotificationCenter.default.post(
name: Notification.ghosttyFocusSplit,
@@ -1082,7 +1130,7 @@ extension Ghostty {
guard let surface = target.target.surface else { return }
guard let surfaceView = self.surfaceView(from: surface) else { return }
guard let window = surfaceView.window as? TerminalWindow else { return }
-
+
switch (mode) {
case .on:
window.level = .floating
diff --git a/macos/Sources/Ghostty/Ghostty.Command.swift b/macos/Sources/Ghostty/Ghostty.Command.swift
new file mode 100644
index 000000000..1479ae92d
--- /dev/null
+++ b/macos/Sources/Ghostty/Ghostty.Command.swift
@@ -0,0 +1,46 @@
+import GhosttyKit
+
+extension Ghostty {
+ /// `ghostty_command_s`
+ struct Command: Sendable {
+ private let cValue: ghostty_command_s
+
+ /// The title of the command.
+ var title: String {
+ String(cString: cValue.title)
+ }
+
+ /// Human-friendly description of what this command will do.
+ var description: String {
+ String(cString: cValue.description)
+ }
+
+ /// The full action that must be performed to invoke this command.
+ var action: String {
+ String(cString: cValue.action)
+ }
+
+ /// Only the key portion of the action so you can compare action types, e.g. `goto_split`
+ /// instead of `goto_split:left`.
+ var actionKey: String {
+ String(cString: cValue.action_key)
+ }
+
+ /// True if this can be performed on this target.
+ var isSupported: Bool {
+ !Self.unsupportedActionKeys.contains(actionKey)
+ }
+
+ /// Unsupported action keys, because they either don't make sense in the context of our
+ /// target platform or they just aren't implemented yet.
+ static let unsupportedActionKeys: [String] = [
+ "toggle_tab_overview",
+ "toggle_window_decorations",
+ "show_gtk_inspector",
+ ]
+
+ init(cValue: ghostty_command_s) {
+ self.cValue = cValue
+ }
+ }
+}
diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift
index d7be4eb5b..241c10632 100644
--- a/macos/Sources/Ghostty/Ghostty.Config.swift
+++ b/macos/Sources/Ghostty/Ghostty.Config.swift
@@ -250,6 +250,17 @@ extension Ghostty {
return String(cString: ptr)
}
+ var macosWindowButtons: MacOSWindowButtons {
+ let defaultValue = MacOSWindowButtons.visible
+ guard let config = self.config else { return defaultValue }
+ var v: UnsafePointer<Int8>? = nil
+ let key = "macos-window-buttons"
+ guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue }
+ guard let ptr = v else { return defaultValue }
+ let str = String(cString: ptr)
+ return MacOSWindowButtons(rawValue: str) ?? defaultValue
+ }
+
var macosTitlebarStyle: String {
let defaultValue = "transparent"
guard let config = self.config else { return defaultValue }
@@ -495,6 +506,14 @@ extension Ghostty {
return v;
}
+ var undoTimeout: Duration {
+ guard let config = self.config else { return .seconds(5) }
+ var v: UInt = 0
+ let key = "undo-timeout"
+ _ = ghostty_config_get(config, &v, key, UInt(key.count))
+ return .milliseconds(v)
+ }
+
var autoUpdate: AutoUpdate? {
guard let config = self.config else { return nil }
var v: UnsafePointer<Int8>? = nil
@@ -539,6 +558,17 @@ extension Ghostty {
_ = ghostty_config_get(config, &v, key, UInt(key.count))
return v
}
+
+ var macosShortcuts: MacShortcuts {
+ let defaultValue = MacShortcuts.ask
+ guard let config = self.config else { return defaultValue }
+ var v: UnsafePointer<Int8>? = nil
+ let key = "macos-shortcuts"
+ guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue }
+ guard let ptr = v else { return defaultValue }
+ let str = String(cString: ptr)
+ return MacShortcuts(rawValue: str) ?? defaultValue
+ }
}
}
@@ -555,6 +585,9 @@ extension Ghostty.Config {
let rawValue: CUnsignedInt
static let system = BellFeatures(rawValue: 1 << 0)
+ static let audio = BellFeatures(rawValue: 1 << 1)
+ static let attention = BellFeatures(rawValue: 1 << 2)
+ static let title = BellFeatures(rawValue: 1 << 3)
}
enum MacHidden : String {
@@ -562,6 +595,12 @@ extension Ghostty.Config {
case always
}
+ enum MacShortcuts: String {
+ case allow
+ case deny
+ case ask
+ }
+
enum ResizeOverlay : String {
case always
case never
diff --git a/macos/Sources/Ghostty/Ghostty.Error.swift b/macos/Sources/Ghostty/Ghostty.Error.swift
new file mode 100644
index 000000000..66f6857bf
--- /dev/null
+++ b/macos/Sources/Ghostty/Ghostty.Error.swift
@@ -0,0 +1,12 @@
+extension Ghostty {
+ /// Possible errors from internal Ghostty calls.
+ enum Error: Swift.Error, CustomLocalizedStringResourceConvertible {
+ case apiFailed
+
+ var localizedStringResource: LocalizedStringResource {
+ switch self {
+ case .apiFailed: return "libghostty API call failed"
+ }
+ }
+ }
+}
diff --git a/macos/Sources/Ghostty/Ghostty.Input.swift b/macos/Sources/Ghostty/Ghostty.Input.swift
index 942ca5973..e05911c06 100644
--- a/macos/Sources/Ghostty/Ghostty.Input.swift
+++ b/macos/Sources/Ghostty/Ghostty.Input.swift
@@ -1,8 +1,11 @@
+import AppIntents
import Cocoa
import SwiftUI
import GhosttyKit
extension Ghostty {
+ struct Input {}
+
// MARK: Keyboard Shortcuts
/// Return the key equivalent for the given trigger.
@@ -91,121 +94,1156 @@ extension Ghostty {
GHOSTTY_KEY_BACKSPACE: .delete,
GHOSTTY_KEY_SPACE: .space,
]
+}
+
+// MARK: Ghostty.Input.KeyEvent
+
+extension Ghostty.Input {
+ /// `ghostty_input_key_s`
+ struct KeyEvent {
+ let action: Action
+ let key: Key
+ let text: String?
+ let composing: Bool
+ let mods: Mods
+ let consumedMods: Mods
+ let unshiftedCodepoint: UInt32
+
+ init(
+ key: Key,
+ action: Action = .press,
+ text: String? = nil,
+ composing: Bool = false,
+ mods: Mods = [],
+ consumedMods: Mods = [],
+ unshiftedCodepoint: UInt32 = 0
+ ) {
+ self.key = key
+ self.action = action
+ self.text = text
+ self.composing = composing
+ self.mods = mods
+ self.consumedMods = consumedMods
+ self.unshiftedCodepoint = unshiftedCodepoint
+ }
+
+ init?(cValue: ghostty_input_key_s) {
+ // Convert action
+ switch cValue.action {
+ case GHOSTTY_ACTION_PRESS: self.action = .press
+ case GHOSTTY_ACTION_RELEASE: self.action = .release
+ case GHOSTTY_ACTION_REPEAT: self.action = .repeat
+ default: self.action = .press
+ }
+
+ // Convert key from keycode
+ guard let key = Key(keyCode: UInt16(cValue.keycode)) else { return nil }
+ self.key = key
+
+ // Convert text
+ if let textPtr = cValue.text {
+ self.text = String(cString: textPtr)
+ } else {
+ self.text = nil
+ }
+
+ // Set composing state
+ self.composing = cValue.composing
+
+ // Convert modifiers
+ self.mods = Mods(cMods: cValue.mods)
+ self.consumedMods = Mods(cMods: cValue.consumed_mods)
+
+ // Set unshifted codepoint
+ self.unshiftedCodepoint = cValue.unshifted_codepoint
+ }
+
+ /// Executes a closure with a temporary C representation of this KeyEvent.
+ ///
+ /// This method safely converts the Swift KeyEntity to a C `ghostty_input_key_s` struct
+ /// and passes it to the provided closure. The C struct is only valid within the closure's
+ /// execution scope. The text field's C string pointer is managed automatically and will
+ /// be invalid after the closure returns.
+ ///
+ /// - Parameter execute: A closure that receives the C struct and returns a value
+ /// - Returns: The value returned by the closure
+ @discardableResult
+ func withCValue<T>(execute: (ghostty_input_key_s) -> T) -> T {
+ var keyEvent = ghostty_input_key_s()
+ keyEvent.action = action.cAction
+ keyEvent.keycode = UInt32(key.keyCode ?? 0)
+ keyEvent.composing = composing
+ keyEvent.mods = mods.cMods
+ keyEvent.consumed_mods = consumedMods.cMods
+ keyEvent.unshifted_codepoint = unshiftedCodepoint
+
+ // Handle text with proper memory management
+ if let text = text {
+ return text.withCString { textPtr in
+ keyEvent.text = textPtr
+ return execute(keyEvent)
+ }
+ } else {
+ keyEvent.text = nil
+ return execute(keyEvent)
+ }
+ }
+ }
+}
+
+// MARK: Ghostty.Input.Action
+
+extension Ghostty.Input {
+ /// `ghostty_input_action_e`
+ enum Action: String, CaseIterable {
+ case release
+ case press
+ case `repeat`
+
+ var cAction: ghostty_input_action_e {
+ switch self {
+ case .release: GHOSTTY_ACTION_RELEASE
+ case .press: GHOSTTY_ACTION_PRESS
+ case .repeat: GHOSTTY_ACTION_REPEAT
+ }
+ }
+ }
+}
+
+extension Ghostty.Input.Action: AppEnum {
+ static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Key Action")
+
+ static var caseDisplayRepresentations: [Ghostty.Input.Action : DisplayRepresentation] = [
+ .release: "Release",
+ .press: "Press",
+ .repeat: "Repeat"
+ ]
+}
+
+// MARK: Ghostty.Input.MouseEvent
+
+extension Ghostty.Input {
+ /// Represents a mouse input event with button state, button type, and modifier keys.
+ struct MouseButtonEvent {
+ let action: MouseState
+ let button: MouseButton
+ let mods: Mods
+
+ init(
+ action: MouseState,
+ button: MouseButton,
+ mods: Mods = []
+ ) {
+ self.action = action
+ self.button = button
+ self.mods = mods
+ }
+
+ /// Creates a MouseEvent from C enum values.
+ ///
+ /// This initializer converts C-style mouse input enums to Swift types.
+ /// Returns nil if any of the C enum values are invalid or unsupported.
+ ///
+ /// - Parameters:
+ /// - state: The mouse button state (press/release)
+ /// - button: The mouse button that was pressed/released
+ /// - mods: The modifier keys held during the mouse event
+ init?(state: ghostty_input_mouse_state_e, button: ghostty_input_mouse_button_e, mods: ghostty_input_mods_e) {
+ // Convert state
+ switch state {
+ case GHOSTTY_MOUSE_RELEASE: self.action = .release
+ case GHOSTTY_MOUSE_PRESS: self.action = .press
+ default: return nil
+ }
+
+ // Convert button
+ switch button {
+ case GHOSTTY_MOUSE_UNKNOWN: self.button = .unknown
+ case GHOSTTY_MOUSE_LEFT: self.button = .left
+ case GHOSTTY_MOUSE_RIGHT: self.button = .right
+ case GHOSTTY_MOUSE_MIDDLE: self.button = .middle
+ default: return nil
+ }
+
+ // Convert modifiers
+ self.mods = Mods(cMods: mods)
+ }
+ }
+
+ /// Represents a mouse position/movement event with coordinates and modifier keys.
+ struct MousePosEvent {
+ let x: Double
+ let y: Double
+ let mods: Mods
+
+ init(
+ x: Double,
+ y: Double,
+ mods: Mods = []
+ ) {
+ self.x = x
+ self.y = y
+ self.mods = mods
+ }
+ }
+
+ /// Represents a mouse scroll event with scroll deltas and modifier keys.
+ struct MouseScrollEvent {
+ let x: Double
+ let y: Double
+ let mods: ScrollMods
+
+ init(
+ x: Double,
+ y: Double,
+ mods: ScrollMods = .init(rawValue: 0)
+ ) {
+ self.x = x
+ self.y = y
+ self.mods = mods
+ }
+ }
+}
+
+// MARK: Ghostty.Input.MouseState
+
+extension Ghostty.Input {
+ /// `ghostty_input_mouse_state_e`
+ enum MouseState: String, CaseIterable {
+ case release
+ case press
+
+ var cMouseState: ghostty_input_mouse_state_e {
+ switch self {
+ case .release: GHOSTTY_MOUSE_RELEASE
+ case .press: GHOSTTY_MOUSE_PRESS
+ }
+ }
+ }
+}
+
+extension Ghostty.Input.MouseState: AppEnum {
+ static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Mouse State")
+
+ static var caseDisplayRepresentations: [Ghostty.Input.MouseState : DisplayRepresentation] = [
+ .release: "Release",
+ .press: "Press"
+ ]
+}
+
+// MARK: Ghostty.Input.MouseButton
+
+extension Ghostty.Input {
+ /// `ghostty_input_mouse_button_e`
+ enum MouseButton: String, CaseIterable {
+ case unknown
+ case left
+ case right
+ case middle
+
+ var cMouseButton: ghostty_input_mouse_button_e {
+ switch self {
+ case .unknown: GHOSTTY_MOUSE_UNKNOWN
+ case .left: GHOSTTY_MOUSE_LEFT
+ case .right: GHOSTTY_MOUSE_RIGHT
+ case .middle: GHOSTTY_MOUSE_MIDDLE
+ }
+ }
+ }
+}
+
+extension Ghostty.Input.MouseButton: AppEnum {
+ static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Mouse Button")
+
+ static var caseDisplayRepresentations: [Ghostty.Input.MouseButton : DisplayRepresentation] = [
+ .unknown: "Unknown",
+ .left: "Left",
+ .right: "Right",
+ .middle: "Middle"
+ ]
+
+ static var allCases: [Ghostty.Input.MouseButton] = [
+ .left,
+ .right,
+ .middle,
+ ]
+}
+
+// MARK: Ghostty.Input.ScrollMods
+
+extension Ghostty.Input {
+ /// `ghostty_input_scroll_mods_t` - Scroll event modifiers
+ ///
+ /// This is a packed bitmask that contains precision and momentum information
+ /// for scroll events, matching the Zig `ScrollMods` packed struct.
+ struct ScrollMods {
+ let rawValue: Int32
+
+ /// True if this is a high-precision scroll event (e.g., trackpad, Magic Mouse)
+ var precision: Bool {
+ rawValue & 0b0000_0001 != 0
+ }
+
+ /// The momentum phase of the scroll event for inertial scrolling
+ var momentum: Momentum {
+ let momentumBits = (rawValue >> 1) & 0b0000_0111
+ return Momentum(rawValue: UInt8(momentumBits)) ?? .none
+ }
+
+ init(precision: Bool = false, momentum: Momentum = .none) {
+ var value: Int32 = 0
+ if precision {
+ value |= 0b0000_0001
+ }
+ value |= Int32(momentum.rawValue) << 1
+ self.rawValue = value
+ }
+
+ init(rawValue: Int32) {
+ self.rawValue = rawValue
+ }
+
+ var cScrollMods: ghostty_input_scroll_mods_t {
+ rawValue
+ }
+ }
+}
+
+// MARK: Ghostty.Input.Momentum
+
+extension Ghostty.Input {
+ /// `ghostty_input_mouse_momentum_e` - Momentum phase for scroll events
+ enum Momentum: UInt8, CaseIterable {
+ case none = 0
+ case began = 1
+ case stationary = 2
+ case changed = 3
+ case ended = 4
+ case cancelled = 5
+ case mayBegin = 6
+
+ var cMomentum: ghostty_input_mouse_momentum_e {
+ switch self {
+ case .none: GHOSTTY_MOUSE_MOMENTUM_NONE
+ case .began: GHOSTTY_MOUSE_MOMENTUM_BEGAN
+ case .stationary: GHOSTTY_MOUSE_MOMENTUM_STATIONARY
+ case .changed: GHOSTTY_MOUSE_MOMENTUM_CHANGED
+ case .ended: GHOSTTY_MOUSE_MOMENTUM_ENDED
+ case .cancelled: GHOSTTY_MOUSE_MOMENTUM_CANCELLED
+ case .mayBegin: GHOSTTY_MOUSE_MOMENTUM_MAY_BEGIN
+ }
+ }
+ }
+}
+
+extension Ghostty.Input.Momentum: AppEnum {
+ static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Scroll Momentum")
+
+ static var caseDisplayRepresentations: [Ghostty.Input.Momentum : DisplayRepresentation] = [
+ .none: "None",
+ .began: "Began",
+ .stationary: "Stationary",
+ .changed: "Changed",
+ .ended: "Ended",
+ .cancelled: "Cancelled",
+ .mayBegin: "May Begin"
+ ]
+}
+
+#if canImport(AppKit)
+import AppKit
+
+extension Ghostty.Input.Momentum {
+ /// Create a Momentum from an NSEvent.Phase
+ init(_ phase: NSEvent.Phase) {
+ switch phase {
+ case .began: self = .began
+ case .stationary: self = .stationary
+ case .changed: self = .changed
+ case .ended: self = .ended
+ case .cancelled: self = .cancelled
+ case .mayBegin: self = .mayBegin
+ default: self = .none
+ }
+ }
+}
+#endif
+
+// MARK: Ghostty.Input.Mods
+
+extension Ghostty.Input {
+ /// `ghostty_input_mods_e`
+ struct Mods: OptionSet {
+ let rawValue: UInt32
+
+ static let none = Mods(rawValue: GHOSTTY_MODS_NONE.rawValue)
+ static let shift = Mods(rawValue: GHOSTTY_MODS_SHIFT.rawValue)
+ static let ctrl = Mods(rawValue: GHOSTTY_MODS_CTRL.rawValue)
+ static let alt = Mods(rawValue: GHOSTTY_MODS_ALT.rawValue)
+ static let `super` = Mods(rawValue: GHOSTTY_MODS_SUPER.rawValue)
+ static let caps = Mods(rawValue: GHOSTTY_MODS_CAPS.rawValue)
+ static let shiftRight = Mods(rawValue: GHOSTTY_MODS_SHIFT_RIGHT.rawValue)
+ static let ctrlRight = Mods(rawValue: GHOSTTY_MODS_CTRL_RIGHT.rawValue)
+ static let altRight = Mods(rawValue: GHOSTTY_MODS_ALT_RIGHT.rawValue)
+ static let superRight = Mods(rawValue: GHOSTTY_MODS_SUPER_RIGHT.rawValue)
+
+ var cMods: ghostty_input_mods_e {
+ ghostty_input_mods_e(rawValue)
+ }
+
+ init(rawValue: UInt32) {
+ self.rawValue = rawValue
+ }
+
+ init(cMods: ghostty_input_mods_e) {
+ self.rawValue = cMods.rawValue
+ }
+
+ init(nsFlags: NSEvent.ModifierFlags) {
+ self.init(cMods: Ghostty.ghosttyMods(nsFlags))
+ }
+
+ var nsFlags: NSEvent.ModifierFlags {
+ Ghostty.eventModifierFlags(mods: cMods)
+ }
+ }
+}
+
+// MARK: Ghostty.Input.Key
+
+extension Ghostty.Input {
+ /// `ghostty_input_key_e`
+ enum Key: String {
+ // Writing System Keys
+ case backquote
+ case backslash
+ case bracketLeft
+ case bracketRight
+ case comma
+ case digit0
+ case digit1
+ case digit2
+ case digit3
+ case digit4
+ case digit5
+ case digit6
+ case digit7
+ case digit8
+ case digit9
+ case equal
+ case intlBackslash
+ case intlRo
+ case intlYen
+ case a
+ case b
+ case c
+ case d
+ case e
+ case f
+ case g
+ case h
+ case i
+ case j
+ case k
+ case l
+ case m
+ case n
+ case o
+ case p
+ case q
+ case r
+ case s
+ case t
+ case u
+ case v
+ case w
+ case x
+ case y
+ case z
+ case minus
+ case period
+ case quote
+ case semicolon
+ case slash
+
+ // Functional Keys
+ case altLeft
+ case altRight
+ case backspace
+ case capsLock
+ case contextMenu
+ case controlLeft
+ case controlRight
+ case enter
+ case metaLeft
+ case metaRight
+ case shiftLeft
+ case shiftRight
+ case space
+ case tab
+ case convert
+ case kanaMode
+ case nonConvert
+
+ // Control Pad Section
+ case delete
+ case end
+ case help
+ case home
+ case insert
+ case pageDown
+ case pageUp
+
+ // Arrow Pad Section
+ case arrowDown
+ case arrowLeft
+ case arrowRight
+ case arrowUp
+
+ // Numpad Section
+ case numLock
+ case numpad0
+ case numpad1
+ case numpad2
+ case numpad3
+ case numpad4
+ case numpad5
+ case numpad6
+ case numpad7
+ case numpad8
+ case numpad9
+ case numpadAdd
+ case numpadBackspace
+ case numpadClear
+ case numpadClearEntry
+ case numpadComma
+ case numpadDecimal
+ case numpadDivide
+ case numpadEnter
+ case numpadEqual
+ case numpadMemoryAdd
+ case numpadMemoryClear
+ case numpadMemoryRecall
+ case numpadMemoryStore
+ case numpadMemorySubtract
+ case numpadMultiply
+ case numpadParenLeft
+ case numpadParenRight
+ case numpadSubtract
+ case numpadSeparator
+ case numpadUp
+ case numpadDown
+ case numpadRight
+ case numpadLeft
+ case numpadBegin
+ case numpadHome
+ case numpadEnd
+ case numpadInsert
+ case numpadDelete
+ case numpadPageUp
+ case numpadPageDown
- // Mapping of event keyCode to ghostty input key values. This is cribbed from
- // glfw mostly since we started as a glfw-based app way back in the day!
- static let keycodeToKey: [UInt16 : ghostty_input_key_e] = [
- 0x1D: GHOSTTY_KEY_DIGIT_0,
- 0x12: GHOSTTY_KEY_DIGIT_1,
- 0x13: GHOSTTY_KEY_DIGIT_2,
- 0x14: GHOSTTY_KEY_DIGIT_3,
- 0x15: GHOSTTY_KEY_DIGIT_4,
- 0x17: GHOSTTY_KEY_DIGIT_5,
- 0x16: GHOSTTY_KEY_DIGIT_6,
- 0x1A: GHOSTTY_KEY_DIGIT_7,
- 0x1C: GHOSTTY_KEY_DIGIT_8,
- 0x19: GHOSTTY_KEY_DIGIT_9,
- 0x00: GHOSTTY_KEY_A,
- 0x0B: GHOSTTY_KEY_B,
- 0x08: GHOSTTY_KEY_C,
- 0x02: GHOSTTY_KEY_D,
- 0x0E: GHOSTTY_KEY_E,
- 0x03: GHOSTTY_KEY_F,
- 0x05: GHOSTTY_KEY_G,
- 0x04: GHOSTTY_KEY_H,
- 0x22: GHOSTTY_KEY_I,
- 0x26: GHOSTTY_KEY_J,
- 0x28: GHOSTTY_KEY_K,
- 0x25: GHOSTTY_KEY_L,
- 0x2E: GHOSTTY_KEY_M,
- 0x2D: GHOSTTY_KEY_N,
- 0x1F: GHOSTTY_KEY_O,
- 0x23: GHOSTTY_KEY_P,
- 0x0C: GHOSTTY_KEY_Q,
- 0x0F: GHOSTTY_KEY_R,
- 0x01: GHOSTTY_KEY_S,
- 0x11: GHOSTTY_KEY_T,
- 0x20: GHOSTTY_KEY_U,
- 0x09: GHOSTTY_KEY_V,
- 0x0D: GHOSTTY_KEY_W,
- 0x07: GHOSTTY_KEY_X,
- 0x10: GHOSTTY_KEY_Y,
- 0x06: GHOSTTY_KEY_Z,
-
- 0x27: GHOSTTY_KEY_QUOTE,
- 0x2A: GHOSTTY_KEY_BACKSLASH,
- 0x2B: GHOSTTY_KEY_COMMA,
- 0x18: GHOSTTY_KEY_EQUAL,
- 0x32: GHOSTTY_KEY_BACKQUOTE,
- 0x21: GHOSTTY_KEY_BRACKET_LEFT,
- 0x1B: GHOSTTY_KEY_MINUS,
- 0x2F: GHOSTTY_KEY_PERIOD,
- 0x1E: GHOSTTY_KEY_BRACKET_RIGHT,
- 0x29: GHOSTTY_KEY_SEMICOLON,
- 0x2C: GHOSTTY_KEY_SLASH,
-
- 0x33: GHOSTTY_KEY_BACKSPACE,
- 0x39: GHOSTTY_KEY_CAPS_LOCK,
- 0x75: GHOSTTY_KEY_DELETE,
- 0x7D: GHOSTTY_KEY_ARROW_DOWN,
- 0x77: GHOSTTY_KEY_END,
- 0x24: GHOSTTY_KEY_ENTER,
- 0x35: GHOSTTY_KEY_ESCAPE,
- 0x7A: GHOSTTY_KEY_F1,
- 0x78: GHOSTTY_KEY_F2,
- 0x63: GHOSTTY_KEY_F3,
- 0x76: GHOSTTY_KEY_F4,
- 0x60: GHOSTTY_KEY_F5,
- 0x61: GHOSTTY_KEY_F6,
- 0x62: GHOSTTY_KEY_F7,
- 0x64: GHOSTTY_KEY_F8,
- 0x65: GHOSTTY_KEY_F9,
- 0x6D: GHOSTTY_KEY_F10,
- 0x67: GHOSTTY_KEY_F11,
- 0x6F: GHOSTTY_KEY_F12,
- 0x69: GHOSTTY_KEY_PRINT_SCREEN,
- 0x6B: GHOSTTY_KEY_F14,
- 0x71: GHOSTTY_KEY_F15,
- 0x6A: GHOSTTY_KEY_F16,
- 0x40: GHOSTTY_KEY_F17,
- 0x4F: GHOSTTY_KEY_F18,
- 0x50: GHOSTTY_KEY_F19,
- 0x5A: GHOSTTY_KEY_F20,
- 0x73: GHOSTTY_KEY_HOME,
- 0x72: GHOSTTY_KEY_INSERT,
- 0x7B: GHOSTTY_KEY_ARROW_LEFT,
- 0x3A: GHOSTTY_KEY_ALT_LEFT,
- 0x3B: GHOSTTY_KEY_CONTROL_LEFT,
- 0x38: GHOSTTY_KEY_SHIFT_LEFT,
- 0x37: GHOSTTY_KEY_META_LEFT,
- 0x47: GHOSTTY_KEY_NUM_LOCK,
- 0x79: GHOSTTY_KEY_PAGE_DOWN,
- 0x74: GHOSTTY_KEY_PAGE_UP,
- 0x7C: GHOSTTY_KEY_ARROW_RIGHT,
- 0x3D: GHOSTTY_KEY_ALT_RIGHT,
- 0x3E: GHOSTTY_KEY_CONTROL_RIGHT,
- 0x3C: GHOSTTY_KEY_SHIFT_RIGHT,
- 0x36: GHOSTTY_KEY_META_RIGHT,
- 0x31: GHOSTTY_KEY_SPACE,
- 0x30: GHOSTTY_KEY_TAB,
- 0x7E: GHOSTTY_KEY_ARROW_UP,
-
- 0x52: GHOSTTY_KEY_NUMPAD_0,
- 0x53: GHOSTTY_KEY_NUMPAD_1,
- 0x54: GHOSTTY_KEY_NUMPAD_2,
- 0x55: GHOSTTY_KEY_NUMPAD_3,
- 0x56: GHOSTTY_KEY_NUMPAD_4,
- 0x57: GHOSTTY_KEY_NUMPAD_5,
- 0x58: GHOSTTY_KEY_NUMPAD_6,
- 0x59: GHOSTTY_KEY_NUMPAD_7,
- 0x5B: GHOSTTY_KEY_NUMPAD_8,
- 0x5C: GHOSTTY_KEY_NUMPAD_9,
- 0x45: GHOSTTY_KEY_NUMPAD_ADD,
- 0x41: GHOSTTY_KEY_NUMPAD_DECIMAL,
- 0x4B: GHOSTTY_KEY_NUMPAD_DIVIDE,
- 0x4C: GHOSTTY_KEY_NUMPAD_ENTER,
- 0x51: GHOSTTY_KEY_NUMPAD_EQUAL,
- 0x43: GHOSTTY_KEY_NUMPAD_MULTIPLY,
- 0x4E: GHOSTTY_KEY_NUMPAD_SUBTRACT,
- ];
+ // Function Section
+ case escape
+ case f1
+ case f2
+ case f3
+ case f4
+ case f5
+ case f6
+ case f7
+ case f8
+ case f9
+ case f10
+ case f11
+ case f12
+ case f13
+ case f14
+ case f15
+ case f16
+ case f17
+ case f18
+ case f19
+ case f20
+ case f21
+ case f22
+ case f23
+ case f24
+ case f25
+ case fn
+ case fnLock
+ case printScreen
+ case scrollLock
+ case pause
+
+ // Media Keys
+ case browserBack
+ case browserFavorites
+ case browserForward
+ case browserHome
+ case browserRefresh
+ case browserSearch
+ case browserStop
+ case eject
+ case launchApp1
+ case launchApp2
+ case launchMail
+ case mediaPlayPause
+ case mediaSelect
+ case mediaStop
+ case mediaTrackNext
+ case mediaTrackPrevious
+ case power
+ case sleep
+ case audioVolumeDown
+ case audioVolumeMute
+ case audioVolumeUp
+ case wakeUp
+
+ // Legacy, Non-standard, and Special Keys
+ case copy
+ case cut
+ case paste
+
+ /// Get a key from a keycode
+ init?(keyCode: UInt16) {
+ if let key = Key.allCases.first(where: { $0.keyCode == keyCode }) {
+ self = key
+ return
+ }
+
+ return nil
+ }
+
+ var cKey: ghostty_input_key_e {
+ switch self {
+ // Writing System Keys
+ case .backquote: GHOSTTY_KEY_BACKQUOTE
+ case .backslash: GHOSTTY_KEY_BACKSLASH
+ case .bracketLeft: GHOSTTY_KEY_BRACKET_LEFT
+ case .bracketRight: GHOSTTY_KEY_BRACKET_RIGHT
+ case .comma: GHOSTTY_KEY_COMMA
+ case .digit0: GHOSTTY_KEY_DIGIT_0
+ case .digit1: GHOSTTY_KEY_DIGIT_1
+ case .digit2: GHOSTTY_KEY_DIGIT_2
+ case .digit3: GHOSTTY_KEY_DIGIT_3
+ case .digit4: GHOSTTY_KEY_DIGIT_4
+ case .digit5: GHOSTTY_KEY_DIGIT_5
+ case .digit6: GHOSTTY_KEY_DIGIT_6
+ case .digit7: GHOSTTY_KEY_DIGIT_7
+ case .digit8: GHOSTTY_KEY_DIGIT_8
+ case .digit9: GHOSTTY_KEY_DIGIT_9
+ case .equal: GHOSTTY_KEY_EQUAL
+ case .intlBackslash: GHOSTTY_KEY_INTL_BACKSLASH
+ case .intlRo: GHOSTTY_KEY_INTL_RO
+ case .intlYen: GHOSTTY_KEY_INTL_YEN
+ case .a: GHOSTTY_KEY_A
+ case .b: GHOSTTY_KEY_B
+ case .c: GHOSTTY_KEY_C
+ case .d: GHOSTTY_KEY_D
+ case .e: GHOSTTY_KEY_E
+ case .f: GHOSTTY_KEY_F
+ case .g: GHOSTTY_KEY_G
+ case .h: GHOSTTY_KEY_H
+ case .i: GHOSTTY_KEY_I
+ case .j: GHOSTTY_KEY_J
+ case .k: GHOSTTY_KEY_K
+ case .l: GHOSTTY_KEY_L
+ case .m: GHOSTTY_KEY_M
+ case .n: GHOSTTY_KEY_N
+ case .o: GHOSTTY_KEY_O
+ case .p: GHOSTTY_KEY_P
+ case .q: GHOSTTY_KEY_Q
+ case .r: GHOSTTY_KEY_R
+ case .s: GHOSTTY_KEY_S
+ case .t: GHOSTTY_KEY_T
+ case .u: GHOSTTY_KEY_U
+ case .v: GHOSTTY_KEY_V
+ case .w: GHOSTTY_KEY_W
+ case .x: GHOSTTY_KEY_X
+ case .y: GHOSTTY_KEY_Y
+ case .z: GHOSTTY_KEY_Z
+ case .minus: GHOSTTY_KEY_MINUS
+ case .period: GHOSTTY_KEY_PERIOD
+ case .quote: GHOSTTY_KEY_QUOTE
+ case .semicolon: GHOSTTY_KEY_SEMICOLON
+ case .slash: GHOSTTY_KEY_SLASH
+
+ // Functional Keys
+ case .altLeft: GHOSTTY_KEY_ALT_LEFT
+ case .altRight: GHOSTTY_KEY_ALT_RIGHT
+ case .backspace: GHOSTTY_KEY_BACKSPACE
+ case .capsLock: GHOSTTY_KEY_CAPS_LOCK
+ case .contextMenu: GHOSTTY_KEY_CONTEXT_MENU
+ case .controlLeft: GHOSTTY_KEY_CONTROL_LEFT
+ case .controlRight: GHOSTTY_KEY_CONTROL_RIGHT
+ case .enter: GHOSTTY_KEY_ENTER
+ case .metaLeft: GHOSTTY_KEY_META_LEFT
+ case .metaRight: GHOSTTY_KEY_META_RIGHT
+ case .shiftLeft: GHOSTTY_KEY_SHIFT_LEFT
+ case .shiftRight: GHOSTTY_KEY_SHIFT_RIGHT
+ case .space: GHOSTTY_KEY_SPACE
+ case .tab: GHOSTTY_KEY_TAB
+ case .convert: GHOSTTY_KEY_CONVERT
+ case .kanaMode: GHOSTTY_KEY_KANA_MODE
+ case .nonConvert: GHOSTTY_KEY_NON_CONVERT
+
+ // Control Pad Section
+ case .delete: GHOSTTY_KEY_DELETE
+ case .end: GHOSTTY_KEY_END
+ case .help: GHOSTTY_KEY_HELP
+ case .home: GHOSTTY_KEY_HOME
+ case .insert: GHOSTTY_KEY_INSERT
+ case .pageDown: GHOSTTY_KEY_PAGE_DOWN
+ case .pageUp: GHOSTTY_KEY_PAGE_UP
+
+ // Arrow Pad Section
+ case .arrowDown: GHOSTTY_KEY_ARROW_DOWN
+ case .arrowLeft: GHOSTTY_KEY_ARROW_LEFT
+ case .arrowRight: GHOSTTY_KEY_ARROW_RIGHT
+ case .arrowUp: GHOSTTY_KEY_ARROW_UP
+
+ // Numpad Section
+ case .numLock: GHOSTTY_KEY_NUM_LOCK
+ case .numpad0: GHOSTTY_KEY_NUMPAD_0
+ case .numpad1: GHOSTTY_KEY_NUMPAD_1
+ case .numpad2: GHOSTTY_KEY_NUMPAD_2
+ case .numpad3: GHOSTTY_KEY_NUMPAD_3
+ case .numpad4: GHOSTTY_KEY_NUMPAD_4
+ case .numpad5: GHOSTTY_KEY_NUMPAD_5
+ case .numpad6: GHOSTTY_KEY_NUMPAD_6
+ case .numpad7: GHOSTTY_KEY_NUMPAD_7
+ case .numpad8: GHOSTTY_KEY_NUMPAD_8
+ case .numpad9: GHOSTTY_KEY_NUMPAD_9
+ case .numpadAdd: GHOSTTY_KEY_NUMPAD_ADD
+ case .numpadBackspace: GHOSTTY_KEY_NUMPAD_BACKSPACE
+ case .numpadClear: GHOSTTY_KEY_NUMPAD_CLEAR
+ case .numpadClearEntry: GHOSTTY_KEY_NUMPAD_CLEAR_ENTRY
+ case .numpadComma: GHOSTTY_KEY_NUMPAD_COMMA
+ case .numpadDecimal: GHOSTTY_KEY_NUMPAD_DECIMAL
+ case .numpadDivide: GHOSTTY_KEY_NUMPAD_DIVIDE
+ case .numpadEnter: GHOSTTY_KEY_NUMPAD_ENTER
+ case .numpadEqual: GHOSTTY_KEY_NUMPAD_EQUAL
+ case .numpadMemoryAdd: GHOSTTY_KEY_NUMPAD_MEMORY_ADD
+ case .numpadMemoryClear: GHOSTTY_KEY_NUMPAD_MEMORY_CLEAR
+ case .numpadMemoryRecall: GHOSTTY_KEY_NUMPAD_MEMORY_RECALL
+ case .numpadMemoryStore: GHOSTTY_KEY_NUMPAD_MEMORY_STORE
+ case .numpadMemorySubtract: GHOSTTY_KEY_NUMPAD_MEMORY_SUBTRACT
+ case .numpadMultiply: GHOSTTY_KEY_NUMPAD_MULTIPLY
+ case .numpadParenLeft: GHOSTTY_KEY_NUMPAD_PAREN_LEFT
+ case .numpadParenRight: GHOSTTY_KEY_NUMPAD_PAREN_RIGHT
+ case .numpadSubtract: GHOSTTY_KEY_NUMPAD_SUBTRACT
+ case .numpadSeparator: GHOSTTY_KEY_NUMPAD_SEPARATOR
+ case .numpadUp: GHOSTTY_KEY_NUMPAD_UP
+ case .numpadDown: GHOSTTY_KEY_NUMPAD_DOWN
+ case .numpadRight: GHOSTTY_KEY_NUMPAD_RIGHT
+ case .numpadLeft: GHOSTTY_KEY_NUMPAD_LEFT
+ case .numpadBegin: GHOSTTY_KEY_NUMPAD_BEGIN
+ case .numpadHome: GHOSTTY_KEY_NUMPAD_HOME
+ case .numpadEnd: GHOSTTY_KEY_NUMPAD_END
+ case .numpadInsert: GHOSTTY_KEY_NUMPAD_INSERT
+ case .numpadDelete: GHOSTTY_KEY_NUMPAD_DELETE
+ case .numpadPageUp: GHOSTTY_KEY_NUMPAD_PAGE_UP
+ case .numpadPageDown: GHOSTTY_KEY_NUMPAD_PAGE_DOWN
+
+ // Function Section
+ case .escape: GHOSTTY_KEY_ESCAPE
+ case .f1: GHOSTTY_KEY_F1
+ case .f2: GHOSTTY_KEY_F2
+ case .f3: GHOSTTY_KEY_F3
+ case .f4: GHOSTTY_KEY_F4
+ case .f5: GHOSTTY_KEY_F5
+ case .f6: GHOSTTY_KEY_F6
+ case .f7: GHOSTTY_KEY_F7
+ case .f8: GHOSTTY_KEY_F8
+ case .f9: GHOSTTY_KEY_F9
+ case .f10: GHOSTTY_KEY_F10
+ case .f11: GHOSTTY_KEY_F11
+ case .f12: GHOSTTY_KEY_F12
+ case .f13: GHOSTTY_KEY_F13
+ case .f14: GHOSTTY_KEY_F14
+ case .f15: GHOSTTY_KEY_F15
+ case .f16: GHOSTTY_KEY_F16
+ case .f17: GHOSTTY_KEY_F17
+ case .f18: GHOSTTY_KEY_F18
+ case .f19: GHOSTTY_KEY_F19
+ case .f20: GHOSTTY_KEY_F20
+ case .f21: GHOSTTY_KEY_F21
+ case .f22: GHOSTTY_KEY_F22
+ case .f23: GHOSTTY_KEY_F23
+ case .f24: GHOSTTY_KEY_F24
+ case .f25: GHOSTTY_KEY_F25
+ case .fn: GHOSTTY_KEY_FN
+ case .fnLock: GHOSTTY_KEY_FN_LOCK
+ case .printScreen: GHOSTTY_KEY_PRINT_SCREEN
+ case .scrollLock: GHOSTTY_KEY_SCROLL_LOCK
+ case .pause: GHOSTTY_KEY_PAUSE
+
+ // Media Keys
+ case .browserBack: GHOSTTY_KEY_BROWSER_BACK
+ case .browserFavorites: GHOSTTY_KEY_BROWSER_FAVORITES
+ case .browserForward: GHOSTTY_KEY_BROWSER_FORWARD
+ case .browserHome: GHOSTTY_KEY_BROWSER_HOME
+ case .browserRefresh: GHOSTTY_KEY_BROWSER_REFRESH
+ case .browserSearch: GHOSTTY_KEY_BROWSER_SEARCH
+ case .browserStop: GHOSTTY_KEY_BROWSER_STOP
+ case .eject: GHOSTTY_KEY_EJECT
+ case .launchApp1: GHOSTTY_KEY_LAUNCH_APP_1
+ case .launchApp2: GHOSTTY_KEY_LAUNCH_APP_2
+ case .launchMail: GHOSTTY_KEY_LAUNCH_MAIL
+ case .mediaPlayPause: GHOSTTY_KEY_MEDIA_PLAY_PAUSE
+ case .mediaSelect: GHOSTTY_KEY_MEDIA_SELECT
+ case .mediaStop: GHOSTTY_KEY_MEDIA_STOP
+ case .mediaTrackNext: GHOSTTY_KEY_MEDIA_TRACK_NEXT
+ case .mediaTrackPrevious: GHOSTTY_KEY_MEDIA_TRACK_PREVIOUS
+ case .power: GHOSTTY_KEY_POWER
+ case .sleep: GHOSTTY_KEY_SLEEP
+ case .audioVolumeDown: GHOSTTY_KEY_AUDIO_VOLUME_DOWN
+ case .audioVolumeMute: GHOSTTY_KEY_AUDIO_VOLUME_MUTE
+ case .audioVolumeUp: GHOSTTY_KEY_AUDIO_VOLUME_UP
+ case .wakeUp: GHOSTTY_KEY_WAKE_UP
+
+ // Legacy, Non-standard, and Special Keys
+ case .copy: GHOSTTY_KEY_COPY
+ case .cut: GHOSTTY_KEY_CUT
+ case .paste: GHOSTTY_KEY_PASTE
+ }
+ }
+
+ // Based on src/input/keycodes.zig
+ var keyCode: UInt16? {
+ switch self {
+ // Writing System Keys
+ case .backquote: return 0x0032
+ case .backslash: return 0x002a
+ case .bracketLeft: return 0x0021
+ case .bracketRight: return 0x001e
+ case .comma: return 0x002b
+ case .digit0: return 0x001d
+ case .digit1: return 0x0012
+ case .digit2: return 0x0013
+ case .digit3: return 0x0014
+ case .digit4: return 0x0015
+ case .digit5: return 0x0017
+ case .digit6: return 0x0016
+ case .digit7: return 0x001a
+ case .digit8: return 0x001c
+ case .digit9: return 0x0019
+ case .equal: return 0x0018
+ case .intlBackslash: return 0x000a
+ case .intlRo: return 0x005e
+ case .intlYen: return 0x005d
+ case .a: return 0x0000
+ case .b: return 0x000b
+ case .c: return 0x0008
+ case .d: return 0x0002
+ case .e: return 0x000e
+ case .f: return 0x0003
+ case .g: return 0x0005
+ case .h: return 0x0004
+ case .i: return 0x0022
+ case .j: return 0x0026
+ case .k: return 0x0028
+ case .l: return 0x0025
+ case .m: return 0x002e
+ case .n: return 0x002d
+ case .o: return 0x001f
+ case .p: return 0x0023
+ case .q: return 0x000c
+ case .r: return 0x000f
+ case .s: return 0x0001
+ case .t: return 0x0011
+ case .u: return 0x0020
+ case .v: return 0x0009
+ case .w: return 0x000d
+ case .x: return 0x0007
+ case .y: return 0x0010
+ case .z: return 0x0006
+ case .minus: return 0x001b
+ case .period: return 0x002f
+ case .quote: return 0x0027
+ case .semicolon: return 0x0029
+ case .slash: return 0x002c
+
+ // Functional Keys
+ case .altLeft: return 0x003a
+ case .altRight: return 0x003d
+ case .backspace: return 0x0033
+ case .capsLock: return 0x0039
+ case .contextMenu: return 0x006e
+ case .controlLeft: return 0x003b
+ case .controlRight: return 0x003e
+ case .enter: return 0x0024
+ case .metaLeft: return 0x0037
+ case .metaRight: return 0x0036
+ case .shiftLeft: return 0x0038
+ case .shiftRight: return 0x003c
+ case .space: return 0x0031
+ case .tab: return 0x0030
+ case .convert: return nil // No Mac keycode
+ case .kanaMode: return nil // No Mac keycode
+ case .nonConvert: return nil // No Mac keycode
+
+ // Control Pad Section
+ case .delete: return 0x0075
+ case .end: return 0x0077
+ case .help: return nil // No Mac keycode
+ case .home: return 0x0073
+ case .insert: return 0x0072
+ case .pageDown: return 0x0079
+ case .pageUp: return 0x0074
+
+ // Arrow Pad Section
+ case .arrowDown: return 0x007d
+ case .arrowLeft: return 0x007b
+ case .arrowRight: return 0x007c
+ case .arrowUp: return 0x007e
+
+ // Numpad Section
+ case .numLock: return 0x0047
+ case .numpad0: return 0x0052
+ case .numpad1: return 0x0053
+ case .numpad2: return 0x0054
+ case .numpad3: return 0x0055
+ case .numpad4: return 0x0056
+ case .numpad5: return 0x0057
+ case .numpad6: return 0x0058
+ case .numpad7: return 0x0059
+ case .numpad8: return 0x005b
+ case .numpad9: return 0x005c
+ case .numpadAdd: return 0x0045
+ case .numpadBackspace: return nil // No Mac keycode
+ case .numpadClear: return nil // No Mac keycode
+ case .numpadClearEntry: return nil // No Mac keycode
+ case .numpadComma: return 0x005f
+ case .numpadDecimal: return 0x0041
+ case .numpadDivide: return 0x004b
+ case .numpadEnter: return 0x004c
+ case .numpadEqual: return 0x0051
+ case .numpadMemoryAdd: return nil // No Mac keycode
+ case .numpadMemoryClear: return nil // No Mac keycode
+ case .numpadMemoryRecall: return nil // No Mac keycode
+ case .numpadMemoryStore: return nil // No Mac keycode
+ case .numpadMemorySubtract: return nil // No Mac keycode
+ case .numpadMultiply: return 0x0043
+ case .numpadParenLeft: return nil // No Mac keycode
+ case .numpadParenRight: return nil // No Mac keycode
+ case .numpadSubtract: return 0x004e
+ case .numpadSeparator: return nil // No Mac keycode
+ case .numpadUp: return nil // No Mac keycode
+ case .numpadDown: return nil // No Mac keycode
+ case .numpadRight: return nil // No Mac keycode
+ case .numpadLeft: return nil // No Mac keycode
+ case .numpadBegin: return nil // No Mac keycode
+ case .numpadHome: return nil // No Mac keycode
+ case .numpadEnd: return nil // No Mac keycode
+ case .numpadInsert: return nil // No Mac keycode
+ case .numpadDelete: return nil // No Mac keycode
+ case .numpadPageUp: return nil // No Mac keycode
+ case .numpadPageDown: return nil // No Mac keycode
+
+ // Function Section
+ case .escape: return 0x0035
+ case .f1: return 0x007a
+ case .f2: return 0x0078
+ case .f3: return 0x0063
+ case .f4: return 0x0076
+ case .f5: return 0x0060
+ case .f6: return 0x0061
+ case .f7: return 0x0062
+ case .f8: return 0x0064
+ case .f9: return 0x0065
+ case .f10: return 0x006d
+ case .f11: return 0x0067
+ case .f12: return 0x006f
+ case .f13: return 0x0069
+ case .f14: return 0x006b
+ case .f15: return 0x0071
+ case .f16: return 0x006a
+ case .f17: return 0x0040
+ case .f18: return 0x004f
+ case .f19: return 0x0050
+ case .f20: return 0x005a
+ case .f21: return nil // No Mac keycode
+ case .f22: return nil // No Mac keycode
+ case .f23: return nil // No Mac keycode
+ case .f24: return nil // No Mac keycode
+ case .f25: return nil // No Mac keycode
+ case .fn: return nil // No Mac keycode
+ case .fnLock: return nil // No Mac keycode
+ case .printScreen: return nil // No Mac keycode
+ case .scrollLock: return nil // No Mac keycode
+ case .pause: return nil // No Mac keycode
+
+ // Media Keys
+ case .browserBack: return nil // No Mac keycode
+ case .browserFavorites: return nil // No Mac keycode
+ case .browserForward: return nil // No Mac keycode
+ case .browserHome: return nil // No Mac keycode
+ case .browserRefresh: return nil // No Mac keycode
+ case .browserSearch: return nil // No Mac keycode
+ case .browserStop: return nil // No Mac keycode
+ case .eject: return nil // No Mac keycode
+ case .launchApp1: return nil // No Mac keycode
+ case .launchApp2: return nil // No Mac keycode
+ case .launchMail: return nil // No Mac keycode
+ case .mediaPlayPause: return nil // No Mac keycode
+ case .mediaSelect: return nil // No Mac keycode
+ case .mediaStop: return nil // No Mac keycode
+ case .mediaTrackNext: return nil // No Mac keycode
+ case .mediaTrackPrevious: return nil // No Mac keycode
+ case .power: return nil // No Mac keycode
+ case .sleep: return nil // No Mac keycode
+ case .audioVolumeDown: return 0x0049
+ case .audioVolumeMute: return 0x004a
+ case .audioVolumeUp: return 0x0048
+ case .wakeUp: return nil // No Mac keycode
+
+ // Legacy, Non-standard, and Special Keys
+ case .copy: return nil // No Mac keycode
+ case .cut: return nil // No Mac keycode
+ case .paste: return nil // No Mac keycode
+ }
+ }
+ }
+}
+
+extension Ghostty.Input.Key: AppEnum {
+ static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Key")
+
+ // Only include keys that have Mac keycodes for App Intents
+ static var allCases: [Ghostty.Input.Key] {
+ return [
+ // Letters (A-Z)
+ .a, .b, .c, .d, .e, .f, .g, .h, .i, .j, .k, .l, .m, .n, .o, .p, .q, .r, .s, .t, .u, .v, .w, .x, .y, .z,
+
+ // Numbers (0-9)
+ .digit0, .digit1, .digit2, .digit3, .digit4, .digit5, .digit6, .digit7, .digit8, .digit9,
+
+ // Common Control Keys
+ .space, .enter, .tab, .backspace, .escape, .delete,
+
+ // Arrow Keys
+ .arrowUp, .arrowDown, .arrowLeft, .arrowRight,
+
+ // Navigation Keys
+ .home, .end, .pageUp, .pageDown, .insert,
+
+ // Function Keys (F1-F20)
+ .f1, .f2, .f3, .f4, .f5, .f6, .f7, .f8, .f9, .f10, .f11, .f12,
+ .f13, .f14, .f15, .f16, .f17, .f18, .f19, .f20,
+
+ // Modifier Keys
+ .shiftLeft, .shiftRight, .controlLeft, .controlRight, .altLeft, .altRight,
+ .metaLeft, .metaRight, .capsLock,
+
+ // Punctuation & Symbols
+ .minus, .equal, .backquote, .bracketLeft, .bracketRight, .backslash,
+ .semicolon, .quote, .comma, .period, .slash,
+
+ // Numpad
+ .numLock, .numpad0, .numpad1, .numpad2, .numpad3, .numpad4, .numpad5,
+ .numpad6, .numpad7, .numpad8, .numpad9, .numpadAdd, .numpadSubtract,
+ .numpadMultiply, .numpadDivide, .numpadDecimal, .numpadEqual,
+ .numpadEnter, .numpadComma,
+
+ // Media Keys
+ .audioVolumeUp, .audioVolumeDown, .audioVolumeMute,
+
+ // International Keys
+ .intlBackslash, .intlRo, .intlYen,
+
+ // Other
+ .contextMenu
+ ]
+ }
+
+ static var caseDisplayRepresentations: [Ghostty.Input.Key : DisplayRepresentation] = [
+ // Letters (A-Z)
+ .a: "A", .b: "B", .c: "C", .d: "D", .e: "E", .f: "F", .g: "G", .h: "H", .i: "I", .j: "J",
+ .k: "K", .l: "L", .m: "M", .n: "N", .o: "O", .p: "P", .q: "Q", .r: "R", .s: "S", .t: "T",
+ .u: "U", .v: "V", .w: "W", .x: "X", .y: "Y", .z: "Z",
+
+ // Numbers (0-9)
+ .digit0: "0", .digit1: "1", .digit2: "2", .digit3: "3", .digit4: "4",
+ .digit5: "5", .digit6: "6", .digit7: "7", .digit8: "8", .digit9: "9",
+
+ // Common Control Keys
+ .space: "Space",
+ .enter: "Enter",
+ .tab: "Tab",
+ .backspace: "Backspace",
+ .escape: "Escape",
+ .delete: "Delete",
+
+ // Arrow Keys
+ .arrowUp: "Up Arrow",
+ .arrowDown: "Down Arrow",
+ .arrowLeft: "Left Arrow",
+ .arrowRight: "Right Arrow",
+
+ // Navigation Keys
+ .home: "Home",
+ .end: "End",
+ .pageUp: "Page Up",
+ .pageDown: "Page Down",
+ .insert: "Insert",
+
+ // Function Keys (F1-F20)
+ .f1: "F1", .f2: "F2", .f3: "F3", .f4: "F4", .f5: "F5", .f6: "F6",
+ .f7: "F7", .f8: "F8", .f9: "F9", .f10: "F10", .f11: "F11", .f12: "F12",
+ .f13: "F13", .f14: "F14", .f15: "F15", .f16: "F16", .f17: "F17",
+ .f18: "F18", .f19: "F19", .f20: "F20",
+
+ // Modifier Keys
+ .shiftLeft: "Left Shift",
+ .shiftRight: "Right Shift",
+ .controlLeft: "Left Control",
+ .controlRight: "Right Control",
+ .altLeft: "Left Alt",
+ .altRight: "Right Alt",
+ .metaLeft: "Left Command",
+ .metaRight: "Right Command",
+ .capsLock: "Caps Lock",
+
+ // Punctuation & Symbols
+ .minus: "Minus (-)",
+ .equal: "Equal (=)",
+ .backquote: "Backtick (`)",
+ .bracketLeft: "Left Bracket ([)",
+ .bracketRight: "Right Bracket (])",
+ .backslash: "Backslash (\\)",
+ .semicolon: "Semicolon (;)",
+ .quote: "Quote (')",
+ .comma: "Comma (,)",
+ .period: "Period (.)",
+ .slash: "Slash (/)",
+
+ // Numpad
+ .numLock: "Num Lock",
+ .numpad0: "Numpad 0", .numpad1: "Numpad 1", .numpad2: "Numpad 2",
+ .numpad3: "Numpad 3", .numpad4: "Numpad 4", .numpad5: "Numpad 5",
+ .numpad6: "Numpad 6", .numpad7: "Numpad 7", .numpad8: "Numpad 8", .numpad9: "Numpad 9",
+ .numpadAdd: "Numpad Add (+)",
+ .numpadSubtract: "Numpad Subtract (-)",
+ .numpadMultiply: "Numpad Multiply (×)",
+ .numpadDivide: "Numpad Divide (÷)",
+ .numpadDecimal: "Numpad Decimal",
+ .numpadEqual: "Numpad Equal",
+ .numpadEnter: "Numpad Enter",
+ .numpadComma: "Numpad Comma",
+
+ // Media Keys
+ .audioVolumeUp: "Volume Up",
+ .audioVolumeDown: "Volume Down",
+ .audioVolumeMute: "Volume Mute",
+
+ // International Keys
+ .intlBackslash: "International Backslash",
+ .intlRo: "International Ro",
+ .intlYen: "International Yen",
+
+ // Other
+ .contextMenu: "Context Menu"
+ ]
}
diff --git a/macos/Sources/Ghostty/Ghostty.SplitNode.swift b/macos/Sources/Ghostty/Ghostty.SplitNode.swift
deleted file mode 100644
index 95c019b1f..000000000
--- a/macos/Sources/Ghostty/Ghostty.SplitNode.swift
+++ /dev/null
@@ -1,494 +0,0 @@
-import SwiftUI
-import Combine
-import GhosttyKit
-
-extension Ghostty {
- /// This enum represents the possible states that a node in the split tree can be in. It is either:
- ///
- /// - noSplit - This is an unsplit, single pane. This contains only a "leaf" which has a single
- /// terminal surface to render.
- /// - horizontal/vertical - This is split into the horizontal or vertical direction. This contains a
- /// "container" which has a recursive top/left SplitNode and bottom/right SplitNode. These
- /// values can further be split infinitely.
- ///
- enum SplitNode: Equatable, Hashable, Codable, Sequence {
- case leaf(Leaf)
- case split(Container)
-
- /// The parent of this node.
- var parent: Container? {
- get {
- switch (self) {
- case .leaf(let leaf):
- return leaf.parent
-
- case .split(let container):
- return container.parent
- }
- }
-
- set {
- switch (self) {
- case .leaf(let leaf):
- leaf.parent = newValue
-
- case .split(let container):
- container.parent = newValue
- }
- }
- }
-
- /// Returns true if the tree is split.
- var isSplit: Bool {
- return if case .leaf = self {
- false
- } else {
- true
- }
- }
-
- func topLeft() -> SurfaceView {
- switch (self) {
- case .leaf(let leaf):
- return leaf.surface
-
- case .split(let container):
- return container.topLeft.topLeft()
- }
- }
-
- /// Returns the view that would prefer receiving focus in this tree. This is always the
- /// top-left-most view. This is used when creating a split or closing a split to find the
- /// next view to send focus to.
- func preferredFocus(_ direction: SplitFocusDirection = .up) -> SurfaceView {
- let container: Container
- switch (self) {
- case .leaf(let leaf):
- // noSplit is easy because there is only one thing to focus
- return leaf.surface
-
- case .split(let c):
- container = c
- }
-
- let node: SplitNode
- switch (direction) {
- case .previous, .up, .left:
- node = container.bottomRight
-
- case .next, .down, .right:
- node = container.topLeft
- }
-
- return node.preferredFocus(direction)
- }
-
- /// When direction is either next or previous, return the first or last
- /// leaf. This can be used when the focus needs to move to a leaf even
- /// after hitting the bottom-right-most or top-left-most surface.
- /// When the direction is not next or previous (such as top, bottom,
- /// left, right), it will be ignored and no leaf will be returned.
- func firstOrLast(_ direction: SplitFocusDirection) -> Leaf? {
- // If there is no parent, simply ignore.
- guard let root = self.parent?.rootContainer() else { return nil }
-
- switch (direction) {
- case .next:
- return root.firstLeaf()
- case .previous:
- return root.lastLeaf()
- default:
- return nil
- }
- }
-
- /// Close the surface associated with this node. This will likely deinitialize the
- /// surface. At this point, the surface view in this node tree can never be used again.
- func close() {
- switch (self) {
- case .leaf(let leaf):
- leaf.surface.close()
-
- case .split(let container):
- container.topLeft.close()
- container.bottomRight.close()
- }
- }
-
- /// Returns true if any surface in the split stack requires quit confirmation.
- func needsConfirmQuit() -> Bool {
- switch (self) {
- case .leaf(let leaf):
- return leaf.surface.needsConfirmQuit
-
- case .split(let container):
- return container.topLeft.needsConfirmQuit() ||
- container.bottomRight.needsConfirmQuit()
- }
- }
-
- /// Returns true if the split tree contains the given view.
- func contains(view: SurfaceView) -> Bool {
- return leaf(for: view) != nil
- }
-
- /// Find a surface view by UUID.
- func findUUID(uuid: UUID) -> SurfaceView? {
- switch (self) {
- case .leaf(let leaf):
- if (leaf.surface.uuid == uuid) {
- return leaf.surface
- }
-
- return nil
-
- case .split(let container):
- return container.topLeft.findUUID(uuid: uuid) ??
- container.bottomRight.findUUID(uuid: uuid)
- }
- }
-
- /// Returns true if the surface borders the top. Assumes the view is in the tree.
- func doesBorderTop(view: SurfaceView) -> Bool {
- switch (self) {
- case .leaf(let leaf):
- return leaf.surface == view
-
- case .split(let container):
- switch (container.direction) {
- case .vertical:
- return container.topLeft.doesBorderTop(view: view)
-
- case .horizontal:
- return container.topLeft.doesBorderTop(view: view) ||
- container.bottomRight.doesBorderTop(view: view)
- }
- }
- }
-
- /// Return the node for the given view if its in the tree.
- func leaf(for view: SurfaceView) -> Leaf? {
- switch (self) {
- case .leaf(let leaf):
- if leaf.surface == view {
- return leaf
- } else {
- return nil
- }
-
- case .split(let container):
- return container.topLeft.leaf(for: view) ??
- container.bottomRight.leaf(for: view)
- }
- }
-
- // MARK: - Sequence
-
- func makeIterator() -> IndexingIterator<[Leaf]> {
- return leaves().makeIterator()
- }
-
- /// Return all the leaves in this split node. This isn't very efficient but our split trees are never super
- /// deep so its not an issue.
- private func leaves() -> [Leaf] {
- switch (self) {
- case .leaf(let leaf):
- return [leaf]
-
- case .split(let container):
- return container.topLeft.leaves() + container.bottomRight.leaves()
- }
- }
-
- // MARK: - Equatable
-
- static func == (lhs: SplitNode, rhs: SplitNode) -> Bool {
- switch (lhs, rhs) {
- case (.leaf(let lhs_v), .leaf(let rhs_v)):
- return lhs_v === rhs_v
- case (.split(let lhs_v), .split(let rhs_v)):
- return lhs_v === rhs_v
- default:
- return false
- }
- }
-
- class Leaf: ObservableObject, Equatable, Hashable, Codable {
- let app: ghostty_app_t
- @Published var surface: SurfaceView
-
- weak var parent: SplitNode.Container?
-
- /// Initialize a new leaf which creates a new terminal surface.
- init(_ app: ghostty_app_t, baseConfig: SurfaceConfiguration? = nil, uuid: UUID? = nil) {
- self.app = app
- self.surface = SurfaceView(app, baseConfig: baseConfig, uuid: uuid)
- }
-
- // MARK: - Hashable
-
- func hash(into hasher: inout Hasher) {
- hasher.combine(app)
- hasher.combine(surface)
- }
-
- // MARK: - Equatable
-
- static func == (lhs: Leaf, rhs: Leaf) -> Bool {
- return lhs.app == rhs.app && lhs.surface === rhs.surface
- }
-
- // MARK: - Codable
-
- enum CodingKeys: String, CodingKey {
- case pwd
- case uuid
- }
-
- required convenience init(from decoder: Decoder) throws {
- // Decoding uses the global Ghostty app
- guard let del = NSApplication.shared.delegate,
- let appDel = del as? AppDelegate,
- let app = appDel.ghostty.app else {
- throw TerminalRestoreError.delegateInvalid
- }
-
- let container = try decoder.container(keyedBy: CodingKeys.self)
- let uuid = UUID(uuidString: try container.decode(String.self, forKey: .uuid))
- var config = SurfaceConfiguration()
- config.workingDirectory = try container.decode(String?.self, forKey: .pwd)
-
- self.init(app, baseConfig: config, uuid: uuid)
- }
-
- func encode(to encoder: Encoder) throws {
- var container = encoder.container(keyedBy: CodingKeys.self)
- try container.encode(surface.pwd, forKey: .pwd)
- try container.encode(surface.uuid.uuidString, forKey: .uuid)
- }
- }
-
- class Container: ObservableObject, Equatable, Hashable, Codable {
- let app: ghostty_app_t
- let direction: SplitViewDirection
-
- @Published var topLeft: SplitNode
- @Published var bottomRight: SplitNode
- @Published var split: CGFloat = 0.5
-
- var resizeEvent: PassthroughSubject<Double, Never> = .init()
-
- weak var parent: SplitNode.Container?
-
- /// A container is always initialized from some prior leaf because a split has to originate
- /// from a non-split value. When initializing, we inherit the leaf's surface and then
- /// initialize a new surface for the new pane.
- init(from: Leaf, direction: SplitViewDirection, baseConfig: SurfaceConfiguration? = nil) {
- self.app = from.app
- self.direction = direction
- self.parent = from.parent
-
- // Initially, both topLeft and bottomRight are in the "nosplit"
- // state since this is a new split.
- self.topLeft = .leaf(from)
-
- let bottomRight: Leaf = .init(app, baseConfig: baseConfig)
- self.bottomRight = .leaf(bottomRight)
-
- from.parent = self
- bottomRight.parent = self
- }
-
- // Move the top left node to the bottom right and vice versa,
- // preserving the size.
- func swap() {
- let topLeft: SplitNode = self.topLeft
- self.topLeft = bottomRight
- self.bottomRight = topLeft
- self.split = 1 - self.split
- }
-
- /// Resize the split by moving the split divider in the given
- /// direction by the given amount. If this container is not split
- /// in the given direction, navigate up the tree until we find a
- /// container that is
- func resize(direction: SplitResizeDirection, amount: UInt16) {
- // We send a resize event to our publisher which will be
- // received by the SplitView.
- switch (self.direction) {
- case .horizontal:
- switch (direction) {
- case .left: resizeEvent.send(-Double(amount))
- case .right: resizeEvent.send(Double(amount))
- default: parent?.resize(direction: direction, amount: amount)
- }
- case .vertical:
- switch (direction) {
- case .up: resizeEvent.send(-Double(amount))
- case .down: resizeEvent.send(Double(amount))
- default: parent?.resize(direction: direction, amount: amount)
- }
- }
- }
-
- /// Equalize the splits in this container. Each split is equalized
- /// based on its weight, i.e. the number of leaves it contains.
- /// This function returns the weight of this container.
- func equalize() -> UInt {
- let topLeftWeight: UInt
- switch (topLeft) {
- case .leaf:
- topLeftWeight = 1
- case .split(let c):
- topLeftWeight = c.equalize()
- }
-
- let bottomRightWeight: UInt
- switch (bottomRight) {
- case .leaf:
- bottomRightWeight = 1
- case .split(let c):
- bottomRightWeight = c.equalize()
- }
-
- let weight = topLeftWeight + bottomRightWeight
- split = Double(topLeftWeight) / Double(weight)
- return weight
- }
-
- /// Returns the top most parent, or this container. Because this
- /// would fall back to use to self, the return value is guaranteed.
- func rootContainer() -> Container {
- guard let parent = self.parent else { return self }
- return parent.rootContainer()
- }
-
- /// Returns the first leaf from the given container. This is most
- /// useful for root container, so that we can find the top-left-most
- /// leaf.
- func firstLeaf() -> Leaf {
- switch (self.topLeft) {
- case .leaf(let leaf):
- return leaf
- case .split(let s):
- return s.firstLeaf()
- }
- }
-
- /// Returns the last leaf from the given container. This is most
- /// useful for root container, so that we can find the bottom-right-
- /// most leaf.
- func lastLeaf() -> Leaf {
- switch (self.bottomRight) {
- case .leaf(let leaf):
- return leaf
- case .split(let s):
- return s.lastLeaf()
- }
- }
-
- // MARK: - Hashable
-
- func hash(into hasher: inout Hasher) {
- hasher.combine(app)
- hasher.combine(direction)
- hasher.combine(topLeft)
- hasher.combine(bottomRight)
- }
-
- // MARK: - Equatable
-
- static func == (lhs: Container, rhs: Container) -> Bool {
- return lhs.app == rhs.app &&
- lhs.direction == rhs.direction &&
- lhs.topLeft == rhs.topLeft &&
- lhs.bottomRight == rhs.bottomRight
- }
-
- // MARK: - Codable
-
- enum CodingKeys: String, CodingKey {
- case direction
- case split
- case topLeft
- case bottomRight
- }
-
- required init(from decoder: Decoder) throws {
- // Decoding uses the global Ghostty app
- guard let del = NSApplication.shared.delegate,
- let appDel = del as? AppDelegate,
- let app = appDel.ghostty.app else {
- throw TerminalRestoreError.delegateInvalid
- }
-
- let container = try decoder.container(keyedBy: CodingKeys.self)
- self.app = app
- self.direction = try container.decode(SplitViewDirection.self, forKey: .direction)
- self.split = try container.decode(CGFloat.self, forKey: .split)
- self.topLeft = try container.decode(SplitNode.self, forKey: .topLeft)
- self.bottomRight = try container.decode(SplitNode.self, forKey: .bottomRight)
-
- // Fix up the parent references
- self.topLeft.parent = self
- self.bottomRight.parent = self
- }
-
- func encode(to encoder: Encoder) throws {
- var container = encoder.container(keyedBy: CodingKeys.self)
- try container.encode(direction, forKey: .direction)
- try container.encode(split, forKey: .split)
- try container.encode(topLeft, forKey: .topLeft)
- try container.encode(bottomRight, forKey: .bottomRight)
- }
- }
-
- /// This keeps track of the "neighbors" of a split: the immediately above/below/left/right
- /// nodes. This is purposely weak so we don't have to worry about memory management
- /// with this (although, it should always be correct).
- struct Neighbors {
- var left: SplitNode?
- var right: SplitNode?
- var up: SplitNode?
- var down: SplitNode?
-
- /// These are the previous/next nodes. It will certainly be one of the above as well
- /// but we keep track of these separately because depending on the split direction
- /// of the containing node, previous may be left OR up (same for next).
- var previous: SplitNode?
- var next: SplitNode?
-
- /// No neighbors, used by the root node.
- static let empty: Self = .init()
-
- /// Get the node for a given direction.
- func get(direction: SplitFocusDirection) -> SplitNode? {
- let map: [SplitFocusDirection : KeyPath<Self, SplitNode?>] = [
- .previous: \.previous,
- .next: \.next,
- .up: \.up,
- .down: \.down,
- .left: \.left,
- .right: \.right,
- ]
-
- guard let path = map[direction] else { return nil }
- return self[keyPath: path]
- }
-
- /// Update multiple keys and return a new copy.
- func update(_ attrs: [WritableKeyPath<Self, SplitNode?>: SplitNode?]) -> Self {
- var clone = self
- attrs.forEach { (key, value) in
- clone[keyPath: key] = value
- }
- return clone
- }
-
- /// True if there are no neighbors
- func isEmpty() -> Bool {
- return self.previous == nil && self.next == nil
- }
- }
- }
-}
diff --git a/macos/Sources/Ghostty/Ghostty.Surface.swift b/macos/Sources/Ghostty/Ghostty.Surface.swift
new file mode 100644
index 000000000..c7198e147
--- /dev/null
+++ b/macos/Sources/Ghostty/Ghostty.Surface.swift
@@ -0,0 +1,149 @@
+import GhosttyKit
+
+extension Ghostty {
+ /// Represents a single surface within Ghostty.
+ ///
+ /// NOTE(mitchellh): This is a work-in-progress class as part of a general refactor
+ /// of our Ghostty data model. At the time of writing there's still a ton of surface
+ /// functionality that is not encapsulated in this class. It is planned to migrate that
+ /// all over.
+ ///
+ /// Wraps a `ghostty_surface_t`
+ final class Surface: Sendable {
+ private let surface: ghostty_surface_t
+
+ /// Read the underlying C value for this surface. This is unsafe because the value will be
+ /// freed when the Surface class is deinitialized.
+ var unsafeCValue: ghostty_surface_t {
+ surface
+ }
+
+ /// Initialize from the C structure.
+ init(cSurface: ghostty_surface_t) {
+ self.surface = cSurface
+ }
+
+ deinit {
+ // deinit is not guaranteed to happen on the main actor and our API
+ // calls into libghostty must happen there so we capture the surface
+ // value so we don't capture `self` and then we detach it in a task.
+ // We can't wait for the task to succeed so this will happen sometime
+ // but that's okay.
+ let surface = self.surface
+ Task.detached { @MainActor in
+ ghostty_surface_free(surface)
+ }
+ }
+
+ /// Send text to the terminal as if it was typed. This doesn't send the key events so keyboard
+ /// shortcuts and other encodings do not take effect.
+ @MainActor
+ func sendText(_ text: String) {
+ let len = text.utf8CString.count
+ if (len == 0) { return }
+
+ text.withCString { ptr in
+ // len includes the null terminator so we do len - 1
+ ghostty_surface_text(surface, ptr, UInt(len - 1))
+ }
+ }
+
+ /// Send a key event to the terminal.
+ ///
+ /// This sends the full key event including modifiers, action type, and text to the terminal.
+ /// Unlike `sendText`, this method processes keyboard shortcuts, key bindings, and terminal
+ /// encoding based on the complete key event information.
+ ///
+ /// - Parameter event: The key event to send to the terminal
+ @MainActor
+ func sendKeyEvent(_ event: Input.KeyEvent) {
+ event.withCValue { cEvent in
+ ghostty_surface_key(surface, cEvent)
+ }
+ }
+
+ /// Whether the terminal has captured mouse input.
+ ///
+ /// When the mouse is captured, the terminal application is receiving mouse events
+ /// directly rather than the host system handling them. This typically occurs when
+ /// a terminal application enables mouse reporting mode.
+ @MainActor
+ var mouseCaptured: Bool {
+ ghostty_surface_mouse_captured(surface)
+ }
+
+ /// Send a mouse button event to the terminal.
+ ///
+ /// This sends a complete mouse button event including the button state (press/release),
+ /// which button was pressed, and any modifier keys that were held during the event.
+ /// The terminal processes this event according to its mouse handling configuration.
+ ///
+ /// - Parameter event: The mouse button event to send to the terminal
+ @MainActor
+ func sendMouseButton(_ event: Input.MouseButtonEvent) {
+ ghostty_surface_mouse_button(
+ surface,
+ event.action.cMouseState,
+ event.button.cMouseButton,
+ event.mods.cMods)
+ }
+
+ /// Send a mouse position event to the terminal.
+ ///
+ /// This reports the current mouse position to the terminal, which may be used
+ /// for mouse tracking, hover effects, or other position-dependent features.
+ /// The terminal will only receive these events if mouse reporting is enabled.
+ ///
+ /// - Parameter event: The mouse position event to send to the terminal
+ @MainActor
+ func sendMousePos(_ event: Input.MousePosEvent) {
+ ghostty_surface_mouse_pos(
+ surface,
+ event.x,
+ event.y,
+ event.mods.cMods)
+ }
+
+ /// Send a mouse scroll event to the terminal.
+ ///
+ /// This sends scroll wheel input to the terminal with delta values for both
+ /// horizontal and vertical scrolling, along with precision and momentum information.
+ /// The terminal processes this according to its scroll handling configuration.
+ ///
+ /// - Parameter event: The mouse scroll event to send to the terminal
+ @MainActor
+ func sendMouseScroll(_ event: Input.MouseScrollEvent) {
+ ghostty_surface_mouse_scroll(
+ surface,
+ event.x,
+ event.y,
+ event.mods.cScrollMods)
+ }
+
+ /// Perform a keybinding action.
+ ///
+ /// The action can be any valid keybind parameter. e.g. `keybind = goto_tab:4`
+ /// you can perform `goto_tab:4` with this.
+ ///
+ /// Returns true if the action was performed. Invalid actions return false.
+ @MainActor
+ func perform(action: String) -> Bool {
+ let len = action.utf8CString.count
+ if (len == 0) { return false }
+ return action.withCString { cString in
+ ghostty_surface_binding_action(surface, cString, UInt(len - 1))
+ }
+ }
+
+ /// Command options for this surface.
+ @MainActor
+ func commands() throws -> [Command] {
+ var ptr: UnsafeMutablePointer<ghostty_command_s>? = nil
+ var count: Int = 0
+ ghostty_surface_commands(surface, &ptr, &count)
+ guard let ptr else { throw Error.apiFailed }
+ let buffer = UnsafeBufferPointer(start: ptr, count: count)
+ return Array(buffer).map { Command(cValue: $0) }.filter { $0.isSupported }
+ }
+ }
+}
diff --git a/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift b/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift
deleted file mode 100644
index 3e942d774..000000000
--- a/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift
+++ /dev/null
@@ -1,472 +0,0 @@
-import SwiftUI
-import GhosttyKit
-
-extension Ghostty {
- /// A spittable terminal view is one where the terminal allows for "splits" (vertical and horizontal) within the
- /// view. The terminal starts in the unsplit state (a plain ol' TerminalView) but responds to changes to the
- /// split direction by splitting the terminal.
- ///
- /// This also allows one split to be "zoomed" at any time.
- struct TerminalSplit: View {
- /// The current state of the root node. This can be set to nil when all surfaces are closed.
- @Binding var node: SplitNode?
-
- /// Non-nil if one of the surfaces in the split tree is currently "zoomed." A zoomed surface
- /// becomes "full screen" on the split tree.
- @State private var zoomedSurface: SurfaceView? = nil
-
- var body: some View {
- ZStack {
- TerminalSplitRoot(
- node: $node,
- zoomedSurface: $zoomedSurface
- )
-
- // If we have a zoomed surface, we overlay that on top of our split
- // root. Our split root will become clear when there is a zoomed
- // surface. We need to keep the split root around so that we don't
- // lose all of the surface state so this must be a ZStack.
- if let surfaceView = zoomedSurface {
- InspectableSurface(surfaceView: surfaceView)
- }
- }
- .focusedValue(\.ghosttySurfaceZoomed, zoomedSurface != nil)
- }
- }
-
- /// The root of a split tree. This sets up the initial SplitNode state and renders. There is only ever
- /// one of these in a split tree.
- private struct TerminalSplitRoot: View {
- /// The root node that we're rendering. This will be set to nil if all the surfaces in this tree close.
- @Binding var node: SplitNode?
-
- /// Keeps track of whether we're in a zoomed split state or not. If one of the splits we own
- /// is in the zoomed state, we clear our body since we expect a zoomed split to overlay
- /// this one.
- @Binding var zoomedSurface: SurfaceView?
-
- var body: some View {
- let center = NotificationCenter.default
- let pubZoom = center.publisher(for: Notification.didToggleSplitZoom)
-
- // If we're zoomed, we don't render anything, we are transparent. This
- // ensures that the View stays around so we don't lose our state, but
- // also that the zoomed view on top can see through if background transparency
- // is enabled.
- if (zoomedSurface == nil) {
- ZStack {
- switch (node) {
- case nil:
- Color(.clear)
-
- case .leaf(let leaf):
- TerminalSplitLeaf(
- leaf: leaf,
- neighbors: .empty,
- node: $node
- )
-
- case .split(let container):
- TerminalSplitContainer(
- neighbors: .empty,
- node: $node,
- container: container
- )
- .onReceive(pubZoom) { onZoom(notification: $0) }
- }
- }
- .id(node) // Needed for change detection on node
- } else {
- // On these events we want to reset the split state and call it.
- let pubSplit = center.publisher(for: Notification.ghosttyNewSplit, object: zoomedSurface!)
- let pubClose = center.publisher(for: Notification.ghosttyCloseSurface, object: zoomedSurface!)
- let pubFocus = center.publisher(for: Notification.ghosttyFocusSplit, object: zoomedSurface!)
-
- ZStack {}
- .onReceive(pubZoom) { onZoomReset(notification: $0) }
- .onReceive(pubSplit) { onZoomReset(notification: $0) }
- .onReceive(pubClose) { onZoomReset(notification: $0) }
- .onReceive(pubFocus) { onZoomReset(notification: $0) }
- }
- }
-
- func onZoom(notification: SwiftUI.Notification) {
- // Our node must be split to receive zooms. You can't zoom an unsplit terminal.
- if case .leaf = node {
- preconditionFailure("TerminalSplitRoom must not be zoom-able if no splits exist")
- }
-
- // Make sure the notification has a surface and that this window owns the surface.
- guard let surfaceView = notification.object as? SurfaceView else { return }
- guard node?.contains(view: surfaceView) ?? false else { return }
-
- // We are in the zoomed state.
- zoomedSurface = surfaceView
-
- // See onZoomReset, same logic.
- DispatchQueue.main.async { Ghostty.moveFocus(to: surfaceView) }
- }
-
- func onZoomReset(notification: SwiftUI.Notification) {
- // Make sure the notification has a surface and that this window owns the surface.
- guard let surfaceView = notification.object as? SurfaceView else { return }
- guard zoomedSurface == surfaceView else { return }
-
- // We are now unzoomed
- zoomedSurface = nil
-
- // We need to stay focused on this view, but the view is going to change
- // superviews. We need to do this async so it happens on the next event loop
- // tick.
- DispatchQueue.main.async {
- Ghostty.moveFocus(to: surfaceView)
-
- // If the notification is not a toggle zoom notification, we want to re-publish
- // it after a short delay so that the split tree has a chance to re-establish
- // so the proper view gets this notification.
- if (notification.name != Notification.didToggleSplitZoom) {
- // We have to wait ANOTHER tick since we just established.
- DispatchQueue.main.async {
- NotificationCenter.default.post(notification)
- }
- }
- }
- }
- }
-
- /// A noSplit leaf node of a split tree.
- private struct TerminalSplitLeaf: View {
- /// The leaf to draw the surface for.
- let leaf: SplitNode.Leaf
-
- /// The neighbors, used for navigation.
- let neighbors: SplitNode.Neighbors
-
- /// The SplitNode that the leaf belongs to. This will be set to nil when leaf is closed.
- @Binding var node: SplitNode?
-
- var body: some View {
- let center = NotificationCenter.default
- let pub = center.publisher(for: Notification.ghosttyNewSplit, object: leaf.surface)
- let pubClose = center.publisher(for: Notification.ghosttyCloseSurface, object: leaf.surface)
- let pubFocus = center.publisher(for: Notification.ghosttyFocusSplit, object: leaf.surface)
- let pubResize = center.publisher(for: Notification.didResizeSplit, object: leaf.surface)
-
- InspectableSurface(surfaceView: leaf.surface, isSplit: !neighbors.isEmpty())
- .onReceive(pub) { onNewSplit(notification: $0) }
- .onReceive(pubClose) { onClose(notification: $0) }
- .onReceive(pubFocus) { onMoveFocus(notification: $0) }
- .onReceive(pubResize) { onResize(notification: $0) }
- }
-
- private func onClose(notification: SwiftUI.Notification) {
- var processAlive = false
- if let valueAny = notification.userInfo?["process_alive"] {
- if let value = valueAny as? Bool {
- processAlive = value
- }
- }
-
- // If the child process is not alive, then we exit immediately
- guard processAlive else {
- node = nil
- return
- }
-
- // If we don't have a window to attach our modal to, we also exit immediately.
- // This should NOT happen.
- guard let window = leaf.surface.window else {
- node = nil
- return
- }
-
- // Confirm close. We use an NSAlert instead of a SwiftUI confirmationDialog
- // due to SwiftUI bugs (see Ghostty #560). To repeat from #560, the bug is that
- // confirmationDialog allows the user to Cmd-W close the alert, but when doing
- // so SwiftUI does not update any of the bindings to note that window is no longer
- // being shown, and provides no callback to detect this.
- let alert = NSAlert()
- alert.messageText = "Close Terminal?"
- alert.informativeText = "The terminal still has a running process. If you close the " +
- "terminal the process will be killed."
- alert.addButton(withTitle: "Close the Terminal")
- alert.addButton(withTitle: "Cancel")
- alert.alertStyle = .warning
- alert.beginSheetModal(for: window, completionHandler: { response in
- switch (response) {
- case .alertFirstButtonReturn:
- alert.window.orderOut(nil)
- node = nil
-
- default:
- break
- }
- })
- }
-
- private func onNewSplit(notification: SwiftUI.Notification) {
- let configAny = notification.userInfo?[Ghostty.Notification.NewSurfaceConfigKey]
- let config = configAny as? SurfaceConfiguration
-
- // Determine our desired direction
- guard let directionAny = notification.userInfo?["direction"] else { return }
- guard let direction = directionAny as? ghostty_action_split_direction_e else { return }
- let splitDirection: SplitViewDirection
- let swap: Bool
- switch (direction) {
- case GHOSTTY_SPLIT_DIRECTION_RIGHT:
- splitDirection = .horizontal
- swap = false
- case GHOSTTY_SPLIT_DIRECTION_LEFT:
- splitDirection = .horizontal
- swap = true
- case GHOSTTY_SPLIT_DIRECTION_DOWN:
- splitDirection = .vertical
- swap = false
- case GHOSTTY_SPLIT_DIRECTION_UP:
- splitDirection = .vertical
- swap = true
-
- default:
- return
- }
-
- // Setup our new container since we are now split
- let container = SplitNode.Container(from: leaf, direction: splitDirection, baseConfig: config)
-
- // Change the parent node. This will trigger the parent to relayout our views.
- node = .split(container)
-
- // See moveFocus comment, we have to run this whenever split changes.
- Ghostty.moveFocus(to: container.bottomRight.preferredFocus(), from: node!.preferredFocus())
-
- // If we are swapping, swap now. We do this after our focus event
- // so that focus is in the right place.
- if swap {
- container.swap()
- }
- }
-
- /// This handles the event to move the split focus (i.e. previous/next) from a keyboard event.
- private func onMoveFocus(notification: SwiftUI.Notification) {
- // Determine our desired direction
- guard let directionAny = notification.userInfo?[Notification.SplitDirectionKey] else { return }
- guard let direction = directionAny as? SplitFocusDirection else { return }
-
- // Find the next surface to move to. In most cases this should be
- // finding the neighbor in provided direction, and focus it. When
- // the neighbor cannot be found based on next or previous direction,
- // this would instead search for first or last leaf and focus it
- // instead, giving the wrap around effect.
- // When other directions are provided, this can be nil, and early
- // returned.
- guard let nextSurface = neighbors.get(direction: direction)?.preferredFocus(direction)
- ?? node?.firstOrLast(direction)?.surface else { return }
-
- Ghostty.moveFocus(
- to: nextSurface
- )
- }
-
- /// Handle a resize event.
- private func onResize(notification: SwiftUI.Notification) {
- // If this leaf is not part of a split then there is nothing to do
- guard let parent = leaf.parent else { return }
-
- guard let directionAny = notification.userInfo?[Ghostty.Notification.ResizeSplitDirectionKey] else { return }
- guard let direction = directionAny as? Ghostty.SplitResizeDirection else { return }
-
- guard let amountAny = notification.userInfo?[Ghostty.Notification.ResizeSplitAmountKey] else { return }
- guard let amount = amountAny as? UInt16 else { return }
-
- parent.resize(direction: direction, amount: amount)
- }
- }
-
- /// This represents a split view that is in the horizontal or vertical split state.
- private struct TerminalSplitContainer: View {
- @EnvironmentObject var ghostty: Ghostty.App
-
- let neighbors: SplitNode.Neighbors
- @Binding var node: SplitNode?
- @StateObject var container: SplitNode.Container
-
- var body: some View {
- SplitView(
- container.direction,
- $container.split,
- dividerColor: ghostty.config.splitDividerColor,
- resizeIncrements: .init(width: 1, height: 1),
- resizePublisher: container.resizeEvent,
- left: {
- let neighborKey: WritableKeyPath<SplitNode.Neighbors, SplitNode?> = container.direction == .horizontal ? \.right : \.down
-
- TerminalSplitNested(
- node: closeableTopLeft(),
- neighbors: neighbors.update([
- neighborKey: container.bottomRight,
- \.next: container.bottomRight,
- ])
- )
- }, right: {
- let neighborKey: WritableKeyPath<SplitNode.Neighbors, SplitNode?> = container.direction == .horizontal ? \.left : \.up
-
- TerminalSplitNested(
- node: closeableBottomRight(),
- neighbors: neighbors.update([
- neighborKey: container.topLeft,
- \.previous: container.topLeft,
- ])
- )
- })
- }
-
- private func closeableTopLeft() -> Binding<SplitNode?> {
- return .init(get: {
- container.topLeft
- }, set: { newValue in
- if let newValue {
- container.topLeft = newValue
- return
- }
-
- // Closing
- container.topLeft.close()
- node = container.bottomRight
-
- switch (node) {
- case .leaf(let l):
- l.parent = container.parent
- case .split(let c):
- c.parent = container.parent
- case .none:
- break
- }
-
- DispatchQueue.main.async {
- Ghostty.moveFocus(
- to: container.bottomRight.preferredFocus(),
- from: container.topLeft.preferredFocus()
- )
- }
- })
- }
-
- private func closeableBottomRight() -> Binding<SplitNode?> {
- return .init(get: {
- container.bottomRight
- }, set: { newValue in
- if let newValue {
- container.bottomRight = newValue
- return
- }
-
- // Closing
- container.bottomRight.close()
- node = container.topLeft
-
- switch (node) {
- case .leaf(let l):
- l.parent = container.parent
- case .split(let c):
- c.parent = container.parent
- case .none:
- break
- }
-
- DispatchQueue.main.async {
- Ghostty.moveFocus(
- to: container.topLeft.preferredFocus(),
- from: container.bottomRight.preferredFocus()
- )
- }
- })
- }
- }
-
-
- /// This is like TerminalSplitRoot, but... not the root. This renders a SplitNode in any state but
- /// requires there be a binding to the parent node.
- private struct TerminalSplitNested: View {
- @Binding var node: SplitNode?
- let neighbors: SplitNode.Neighbors
-
- var body: some View {
- Group {
- switch (node) {
- case nil:
- Color(.clear)
-
- case .leaf(let leaf):
- TerminalSplitLeaf(
- leaf: leaf,
- neighbors: neighbors,
- node: $node
- )
-
- case .split(let container):
- TerminalSplitContainer(
- neighbors: neighbors,
- node: $node,
- container: container
- )
- }
- }
- .id(node)
- }
- }
-
- /// When changing the split state, or going full screen (native or non), the terminal view
- /// will lose focus. There has to be some nice SwiftUI-native way to fix this but I can't
- /// figure it out so we're going to do this hacky thing to bring focus back to the terminal
- /// that should have it.
- static func moveFocus(
- to: SurfaceView,
- from: SurfaceView? = nil,
- delay: TimeInterval? = nil
- ) {
- // The whole delay machinery is a bit of a hack to work around a
- // situation where the window is destroyed and the surface view
- // will never be attached to a window. Realistically, we should
- // handle this upstream but we also don't want this function to be
- // a source of infinite loops.
-
- // Our max delay before we give up
- let maxDelay: TimeInterval = 0.5
- guard (delay ?? 0) < maxDelay else { return }
-
- // We start at a 50 millisecond delay and do a doubling backoff
- let nextDelay: TimeInterval = if let delay {
- delay * 2
- } else {
- // 100 milliseconds
- 0.05
- }
-
- let work: DispatchWorkItem = .init {
- // If the callback runs before the surface is attached to a view
- // then the window will be nil. We just reschedule in that case.
- guard let window = to.window else {
- moveFocus(to: to, from: from, delay: nextDelay)
- return
- }
-
- // If we had a previously focused node and its not where we're sending
- // focus, make sure that we explicitly tell it to lose focus. In theory
- // we should NOT have to do this but the focus callback isn't getting
- // called for some reason.
- if let from = from {
- _ = from.resignFirstResponder()
- }
-
- window.makeFirstResponder(to)
- }
-
- let queue = DispatchQueue.main
- if let delay {
- queue.asyncAfter(deadline: .now() + delay, execute: work)
- } else {
- queue.async(execute: work)
- }
- }
-}
diff --git a/macos/Sources/Ghostty/InspectorView.swift b/macos/Sources/Ghostty/InspectorView.swift
index a6e80bd47..8008e49c2 100644
--- a/macos/Sources/Ghostty/InspectorView.swift
+++ b/macos/Sources/Ghostty/InspectorView.swift
@@ -337,9 +337,9 @@ extension Ghostty {
private func keyAction(_ action: ghostty_input_action_e, event: NSEvent) {
guard let inspector = self.inspector else { return }
- guard let key = Ghostty.keycodeToKey[event.keyCode] else { return }
+ guard let key = Ghostty.Input.Key(keyCode: event.keyCode) else { return }
let mods = Ghostty.ghosttyMods(event.modifierFlags)
- ghostty_inspector_key(inspector, action, key, mods)
+ ghostty_inspector_key(inspector, action, key.cKey, mods)
}
// MARK: NSTextInputClient
diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift
index 30d5573df..125a09825 100644
--- a/macos/Sources/Ghostty/Package.swift
+++ b/macos/Sources/Ghostty/Package.swift
@@ -19,6 +19,15 @@ struct Ghostty {
static let userNotificationActionShow = "com.mitchellh.ghostty.userNotification.Show"
}
+// MARK: C Extensions
+
+/// A command is fully self-contained so it is Sendable.
+extension ghostty_command_s: @unchecked @retroactive Sendable {}
+
+/// A surface is sendable because it is just a reference type. Using the surface in parameters
+/// may be unsafe but the value itself is safe to send across threads.
+extension ghostty_surface_t: @unchecked @retroactive Sendable {}
+
// MARK: Build Info
extension Ghostty {
@@ -239,6 +248,12 @@ extension Ghostty {
case chrome
}
+ /// Enum for the macos-window-buttons config option
+ enum MacOSWindowButtons: String {
+ case visible
+ case hidden
+ }
+
/// Enum for the macos-titlebar-proxy-icon config option
enum MacOSTitlebarProxyIcon: String {
case visible
diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift
index 1e9a4cfef..aa4de5178 100644
--- a/macos/Sources/Ghostty/SurfaceView.swift
+++ b/macos/Sources/Ghostty/SurfaceView.swift
@@ -59,7 +59,7 @@ extension Ghostty {
var title: String {
var result = surfaceView.title
- if (surfaceView.bell) {
+ if (surfaceView.bell && ghostty.config.bellFeatures.contains(.title)) {
result = "🔔 \(result)"
}
@@ -79,7 +79,7 @@ extension Ghostty {
let pubResign = center.publisher(for: NSWindow.didResignKeyNotification)
#endif
- Surface(view: surfaceView, size: geo.size)
+ SurfaceRepresentable(view: surfaceView, size: geo.size)
.focused($surfaceFocus)
.focusedValue(\.ghosttySurfacePwd, surfaceView.pwd)
.focusedValue(\.ghosttySurfaceView, surfaceView)
@@ -301,8 +301,12 @@ extension Ghostty {
if let instant = focusInstant {
let d = instant.duration(to: ContinuousClock.now)
if (d < .milliseconds(500)) {
- // Avoid this size completely.
- lastSize = geoSize
+ // Avoid this size completely. We can't set values during
+ // view updates so we have to defer this to another tick.
+ DispatchQueue.main.async {
+ lastSize = geoSize
+ }
+
return true;
}
}
@@ -377,7 +381,7 @@ extension Ghostty {
/// We just wrap an AppKit NSView here at the moment so that we can behave as low level as possible
/// since that is what the Metal renderer in Ghostty expects. In the future, it may make more sense to
/// wrap an MTKView and use that, but for legacy reasons we didn't do that to begin with.
- struct Surface: OSViewRepresentable {
+ struct SurfaceRepresentable: OSViewRepresentable {
/// The view to render for the terminal surface.
let view: SurfaceView
@@ -414,28 +418,48 @@ extension Ghostty {
/// Explicit command to set
var command: String? = nil
+
+ /// Environment variables to set for the terminal
+ var environmentVariables: [String: String] = [:]
+
+ /// Extra input to send as stdin
+ var initialInput: String? = nil
init() {}
init(from config: ghostty_surface_config_s) {
self.fontSize = config.font_size
- self.workingDirectory = String.init(cString: config.working_directory, encoding: .utf8)
- self.command = String.init(cString: config.command, encoding: .utf8)
+ if let workingDirectory = config.working_directory {
+ self.workingDirectory = String.init(cString: workingDirectory, encoding: .utf8)
+ }
+ if let command = config.command {
+ self.command = String.init(cString: command, encoding: .utf8)
+ }
+
+ // Convert the C env vars to Swift dictionary
+ if config.env_var_count > 0, let envVars = config.env_vars {
+ for i in 0..<config.env_var_count {
+ let envVar = envVars[i]
+ if let key = String(cString: envVar.key, encoding: .utf8),
+ let value = String(cString: envVar.value, encoding: .utf8) {
+ self.environmentVariables[key] = value
+ }
+ }
+ }
}
- /// Returns the ghostty configuration for this surface configuration struct. The memory
- /// in the returned struct is only valid as long as this struct is retained.
- func ghosttyConfig(view: SurfaceView) -> ghostty_surface_config_s {
+ /// Provides a C-compatible ghostty configuration within a closure. The configuration
+ /// and all its string pointers are only valid within the closure.
+ func withCValue<T>(view: SurfaceView, _ body: (inout ghostty_surface_config_s) throws -> T) rethrows -> T {
var config = ghostty_surface_config_new()
config.userdata = Unmanaged.passUnretained(view).toOpaque()
- #if os(macOS)
+#if os(macOS)
config.platform_tag = GHOSTTY_PLATFORM_MACOS
config.platform = ghostty_platform_u(macos: ghostty_platform_macos_s(
nsview: Unmanaged.passUnretained(view).toOpaque()
))
config.scale_factor = NSScreen.main!.backingScaleFactor
-
- #elseif os(iOS)
+#elseif os(iOS)
config.platform_tag = GHOSTTY_PLATFORM_IOS
config.platform = ghostty_platform_u(ios: ghostty_platform_ios_s(
uiview: Unmanaged.passUnretained(view).toOpaque()
@@ -445,21 +469,108 @@ extension Ghostty {
// probably set this to some default, then modify the scale factor through
// libghostty APIs when a UIView is attached to a window/scene. TODO.
config.scale_factor = UIScreen.main.scale
- #else
- #error("unsupported target")
- #endif
+#else
+#error("unsupported target")
+#endif
+
+ // Zero is our default value that means to inherit the font size.
+ config.font_size = fontSize ?? 0
+
+ // Use withCString to ensure strings remain valid for the duration of the closure
+ return try workingDirectory.withCString { cWorkingDir in
+ config.working_directory = cWorkingDir
+
+ return try command.withCString { cCommand in
+ config.command = cCommand
+
+ return try initialInput.withCString { cInput in
+ config.initial_input = cInput
+
+ // Convert dictionary to arrays for easier processing
+ let keys = Array(environmentVariables.keys)
+ let values = Array(environmentVariables.values)
+
+ // Create C strings for all keys and values
+ return try keys.withCStrings { keyCStrings in
+ return try values.withCStrings { valueCStrings in
+ // Create array of ghostty_env_var_s
+ var envVars = Array<ghostty_env_var_s>()
+ envVars.reserveCapacity(environmentVariables.count)
+ for i in 0..<environmentVariables.count {
+ envVars.append(ghostty_env_var_s(
+ key: keyCStrings[i],
+ value: valueCStrings[i]
+ ))
+ }
+
+ return try envVars.withUnsafeMutableBufferPointer { buffer in
+ config.env_vars = buffer.baseAddress
+ config.env_var_count = environmentVariables.count
+ return try body(&config)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ #if canImport(AppKit)
+ /// When changing the split state, or going full screen (native or non), the terminal view
+ /// will lose focus. There has to be some nice SwiftUI-native way to fix this but I can't
+ /// figure it out so we're going to do this hacky thing to bring focus back to the terminal
+ /// that should have it.
+ static func moveFocus(
+ to: SurfaceView,
+ from: SurfaceView? = nil,
+ delay: TimeInterval? = nil
+ ) {
+ // The whole delay machinery is a bit of a hack to work around a
+ // situation where the window is destroyed and the surface view
+ // will never be attached to a window. Realistically, we should
+ // handle this upstream but we also don't want this function to be
+ // a source of infinite loops.
+
+ // Our max delay before we give up
+ let maxDelay: TimeInterval = 0.5
+ guard (delay ?? 0) < maxDelay else { return }
+
+ // We start at a 50 millisecond delay and do a doubling backoff
+ let nextDelay: TimeInterval = if let delay {
+ delay * 2
+ } else {
+ // 100 milliseconds
+ 0.05
+ }
- if let fontSize = fontSize { config.font_size = fontSize }
- if let workingDirectory = workingDirectory {
- config.working_directory = (workingDirectory as NSString).utf8String
+ let work: DispatchWorkItem = .init {
+ // If the callback runs before the surface is attached to a view
+ // then the window will be nil. We just reschedule in that case.
+ guard let window = to.window else {
+ moveFocus(to: to, from: from, delay: nextDelay)
+ return
}
- if let command = command {
- config.command = (command as NSString).utf8String
+
+ // If we had a previously focused node and its not where we're sending
+ // focus, make sure that we explicitly tell it to lose focus. In theory
+ // we should NOT have to do this but the focus callback isn't getting
+ // called for some reason.
+ if let from = from {
+ _ = from.resignFirstResponder()
}
- return config
+ window.makeFirstResponder(to)
+ }
+
+ let queue = DispatchQueue.main
+ if let delay {
+ queue.asyncAfter(deadline: .now() + delay, execute: work)
+ } else {
+ queue.async(execute: work)
}
}
+ #endif
}
// MARK: Surface Environment Keys
@@ -502,15 +613,6 @@ extension FocusedValues {
typealias Value = String
}
- var ghosttySurfaceZoomed: Bool? {
- get { self[FocusedGhosttySurfaceZoomed.self] }
- set { self[FocusedGhosttySurfaceZoomed.self] = newValue }
- }
-
- struct FocusedGhosttySurfaceZoomed: FocusedValueKey {
- typealias Value = Bool
- }
-
var ghosttySurfaceCellSize: OSSize? {
get { self[FocusedGhosttySurfaceCellSize.self] }
set { self[FocusedGhosttySurfaceCellSize.self] = newValue }
diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift
index 8e8838471..0a2f2c847 100644
--- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift
+++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift
@@ -6,7 +6,7 @@ import GhosttyKit
extension Ghostty {
/// The NSView implementation for a terminal surface.
- class SurfaceView: OSView, ObservableObject {
+ class SurfaceView: OSView, ObservableObject, Codable {
/// Unique ID per surface
let uuid: UUID
@@ -92,6 +92,12 @@ extension Ghostty {
return ghostty_surface_needs_confirm_quit(surface)
}
+ // Returns true if the process in this surface has exited.
+ var processExited: Bool {
+ guard let surface = self.surface else { return true }
+ return ghostty_surface_process_exited(surface)
+ }
+
// Returns the inspector instance for this surface, or nil if the
// surface has been closed.
var inspector: ghostty_inspector_t? {
@@ -109,10 +115,20 @@ extension Ghostty {
}
}
+ /// Returns the data model for this surface.
+ ///
+ /// Note: eventually, all surface access will be through this, but presently its in a transition
+ /// state so we're mixing this with direct surface access.
+ private(set) var surfaceModel: Ghostty.Surface?
+
+ /// Returns the underlying C value for the surface. See "note" on surfaceModel.
+ var surface: ghostty_surface_t? {
+ surfaceModel?.unsafeCValue
+ }
+
// Notification identifiers associated with this surface
var notificationIdentifiers: Set<String> = []
- private(set) var surface: ghostty_surface_t?
private var markedText: NSMutableAttributedString
private(set) var focused: Bool = true
private var prevPressureStage: Int = 0
@@ -132,16 +148,16 @@ extension Ghostty {
// by the user, this is set to the prior value (which may be empty, but non-nil).
private var titleFromTerminal: String?
+ // The cached contents of the screen.
+ private(set) var cachedScreenContents: CachedValue<String>
+ private(set) var cachedVisibleContents: CachedValue<String>
+
/// Event monitor (see individual events for why)
private var eventMonitor: Any? = nil
// We need to support being a first responder so that we can get input events
override var acceptsFirstResponder: Bool { return true }
- // I don't think we need this but this lets us know we should redraw our layer
- // so we'll use that to tell ghostty to refresh.
- override var wantsUpdateLayer: Bool { return true }
-
init(_ app: ghostty_app_t, baseConfig: SurfaceConfiguration? = nil, uuid: UUID? = nil) {
self.markedText = NSMutableAttributedString()
self.uuid = uuid ?? .init()
@@ -153,11 +169,59 @@ extension Ghostty {
self.derivedConfig = DerivedConfig()
}
+ // We need to initialize this so it does something but we want to set
+ // it back up later so we can reference `self`. This is a hack we should
+ // fix at some point.
+ self.cachedScreenContents = .init(duration: .milliseconds(500)) { "" }
+ self.cachedVisibleContents = self.cachedScreenContents
+
// Initialize with some default frame size. The important thing is that this
// is non-zero so that our layer bounds are non-zero so that our renderer
// can do SOMETHING.
super.init(frame: NSMakeRect(0, 0, 800, 600))
+ // Our cache of screen data
+ cachedScreenContents = .init(duration: .milliseconds(500)) { [weak self] in
+ guard let self else { return "" }
+ guard let surface = self.surface else { return "" }
+ var text = ghostty_text_s()
+ let sel = ghostty_selection_s(
+ top_left: ghostty_point_s(
+ tag: GHOSTTY_POINT_SCREEN,
+ coord: GHOSTTY_POINT_COORD_TOP_LEFT,
+ x: 0,
+ y: 0),
+ bottom_right: ghostty_point_s(
+ tag: GHOSTTY_POINT_SCREEN,
+ coord: GHOSTTY_POINT_COORD_BOTTOM_RIGHT,
+ x: 0,
+ y: 0),
+ rectangle: false)
+ guard ghostty_surface_read_text(surface, sel, &text) else { return "" }
+ defer { ghostty_surface_free_text(surface, &text) }
+ return String(cString: text.text)
+ }
+ cachedVisibleContents = .init(duration: .milliseconds(500)) { [weak self] in
+ guard let self else { return "" }
+ guard let surface = self.surface else { return "" }
+ var text = ghostty_text_s()
+ let sel = ghostty_selection_s(
+ top_left: ghostty_point_s(
+ tag: GHOSTTY_POINT_VIEWPORT,
+ coord: GHOSTTY_POINT_COORD_TOP_LEFT,
+ x: 0,
+ y: 0),
+ bottom_right: ghostty_point_s(
+ tag: GHOSTTY_POINT_VIEWPORT,
+ coord: GHOSTTY_POINT_COORD_BOTTOM_RIGHT,
+ x: 0,
+ y: 0),
+ rectangle: false)
+ guard ghostty_surface_read_text(surface, sel, &text) else { return "" }
+ defer { ghostty_surface_free_text(surface, &text) }
+ return String(cString: text.text)
+ }
+
// Set a timer to show the ghost emoji after 500ms if no title is set
titleFallbackTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { [weak self] _ in
if let self = self, self.title.isEmpty {
@@ -222,12 +286,14 @@ extension Ghostty {
// Setup our surface. This will also initialize all the terminal IO.
let surface_cfg = baseConfig ?? SurfaceConfiguration()
- var surface_cfg_c = surface_cfg.ghosttyConfig(view: self)
- guard let surface = ghostty_surface_new(app, &surface_cfg_c) else {
- self.error = AppError.surfaceCreateError
+ let surface = surface_cfg.withCValue(view: self) { surface_cfg_c in
+ ghostty_surface_new(app, &surface_cfg_c)
+ }
+ guard let surface = surface else {
+ self.error = Ghostty.Error.apiFailed
return
}
- self.surface = surface;
+ self.surfaceModel = Ghostty.Surface(cSurface: surface)
// Setup our tracking area so we get mouse moved events
updateTrackingAreas()
@@ -279,22 +345,9 @@ extension Ghostty {
// Remove ourselves from secure input if we have to
SecureInput.shared.removeScoped(ObjectIdentifier(self))
- guard let surface = self.surface else { return }
- ghostty_surface_free(surface)
- }
-
- /// Close the surface early. This will free the associated Ghostty surface and the view will
- /// no longer render. The view can never be used again. This is a way for us to free the
- /// Ghostty resources while references may still be held to this view. I've found that SwiftUI
- /// tends to hold this view longer than it should so we free the expensive stuff explicitly.
- func close() {
// Remove any notifications associated with this surface
let identifiers = Array(self.notificationIdentifiers)
UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: identifiers)
-
- guard let surface = self.surface else { return }
- ghostty_surface_free(surface)
- self.surface = nil
}
func focusDidChange(_ focused: Bool) {
@@ -314,6 +367,14 @@ extension Ghostty {
// We unset our bell state if we gained focus
bell = false
+
+ // Remove any notifications for this surface once we gain focus.
+ if !notificationIdentifiers.isEmpty {
+ UNUserNotificationCenter.current()
+ .removeDeliveredNotifications(
+ withIdentifiers: Array(notificationIdentifiers))
+ self.notificationIdentifiers = []
+ }
}
}
@@ -667,11 +728,6 @@ extension Ghostty {
setSurfaceSize(width: UInt32(fbFrame.size.width), height: UInt32(fbFrame.size.height))
}
- override func updateLayer() {
- guard let surface = self.surface else { return }
- ghostty_surface_draw(surface);
- }
-
override func mouseDown(with event: NSEvent) {
guard let surface = self.surface else { return }
let mods = Ghostty.ghosttyMods(event.modifierFlags)
@@ -745,19 +801,23 @@ extension Ghostty {
override func mouseEntered(with event: NSEvent) {
super.mouseEntered(with: event)
- guard let surface = self.surface else { return }
+ guard let surfaceModel else { return }
// On mouse enter we need to reset our cursor position. This is
// super important because we set it to -1/-1 on mouseExit and
// lots of mouse logic (i.e. whether to send mouse reports) depend
// on the position being in the viewport if it is.
let pos = self.convert(event.locationInWindow, from: nil)
- let mods = Ghostty.ghosttyMods(event.modifierFlags)
- ghostty_surface_mouse_pos(surface, pos.x, frame.height - pos.y, mods)
+ let mouseEvent = Ghostty.Input.MousePosEvent(
+ x: pos.x,
+ y: frame.height - pos.y,
+ mods: .init(nsFlags: event.modifierFlags)
+ )
+ surfaceModel.sendMousePos(mouseEvent)
}
override func mouseExited(with event: NSEvent) {
- guard let surface = self.surface else { return }
+ guard let surfaceModel else { return }
// If the mouse is being dragged then we don't have to emit
// this because we get mouse drag events even if we've already
@@ -767,17 +827,25 @@ extension Ghostty {
}
// Negative values indicate cursor has left the viewport
- let mods = Ghostty.ghosttyMods(event.modifierFlags)
- ghostty_surface_mouse_pos(surface, -1, -1, mods)
+ let mouseEvent = Ghostty.Input.MousePosEvent(
+ x: -1,
+ y: -1,
+ mods: .init(nsFlags: event.modifierFlags)
+ )
+ surfaceModel.sendMousePos(mouseEvent)
}
override func mouseMoved(with event: NSEvent) {
- guard let surface = self.surface else { return }
+ guard let surfaceModel else { return }
// Convert window position to view position. Note (0, 0) is bottom left.
let pos = self.convert(event.locationInWindow, from: nil)
- let mods = Ghostty.ghosttyMods(event.modifierFlags)
- ghostty_surface_mouse_pos(surface, pos.x, frame.height - pos.y, mods)
+ let mouseEvent = Ghostty.Input.MousePosEvent(
+ x: pos.x,
+ y: frame.height - pos.y,
+ mods: .init(nsFlags: event.modifierFlags)
+ )
+ surfaceModel.sendMousePos(mouseEvent)
// Handle focus-follows-mouse
if let window,
@@ -803,16 +871,13 @@ extension Ghostty {
}
override func scrollWheel(with event: NSEvent) {
- guard let surface = self.surface else { return }
-
- // Builds up the "input.ScrollMods" bitmask
- var mods: Int32 = 0
+ guard let surfaceModel else { return }
var x = event.scrollingDeltaX
var y = event.scrollingDeltaY
- if event.hasPreciseScrollingDeltas {
- mods = 1
-
+ let precision = event.hasPreciseScrollingDeltas
+
+ if precision {
// We do a 2x speed multiplier. This is subjective, it "feels" better to me.
x *= 2;
y *= 2;
@@ -820,29 +885,12 @@ extension Ghostty {
// TODO(mitchellh): do we have to scale the x/y here by window scale factor?
}
- // Determine our momentum value
- var momentum: ghostty_input_mouse_momentum_e = GHOSTTY_MOUSE_MOMENTUM_NONE
- switch (event.momentumPhase) {
- case .began:
- momentum = GHOSTTY_MOUSE_MOMENTUM_BEGAN
- case .stationary:
- momentum = GHOSTTY_MOUSE_MOMENTUM_STATIONARY
- case .changed:
- momentum = GHOSTTY_MOUSE_MOMENTUM_CHANGED
- case .ended:
- momentum = GHOSTTY_MOUSE_MOMENTUM_ENDED
- case .cancelled:
- momentum = GHOSTTY_MOUSE_MOMENTUM_CANCELLED
- case .mayBegin:
- momentum = GHOSTTY_MOUSE_MOMENTUM_MAY_BEGIN
- default:
- break
- }
-
- // Pack our momentum value into the mods bitmask
- mods |= Int32(momentum.rawValue) << 1
-
- ghostty_surface_mouse_scroll(surface, x, y, mods)
+ let scrollEvent = Ghostty.Input.MouseScrollEvent(
+ x: x,
+ y: y,
+ mods: .init(precision: precision, momentum: .init(event.momentumPhase))
+ )
+ surfaceModel.sendMouseScroll(scrollEvent)
}
override func pressureChange(with event: NSEvent) {
@@ -1209,11 +1257,10 @@ extension Ghostty {
guard let surface = self.surface else { return super.quickLook(with: event) }
// Grab the text under the cursor
- var info: ghostty_selection_s = ghostty_selection_s();
- let text = String(unsafeUninitializedCapacity: 1000000) {
- Int(ghostty_surface_quicklook_word(surface, $0.baseAddress, UInt($0.count), &info))
- }
- guard !text.isEmpty else { return super.quickLook(with: event) }
+ var text = ghostty_text_s()
+ guard ghostty_surface_quicklook_word(surface, &text) else { return super.quickLook(with: event) }
+ defer { ghostty_surface_free_text(surface, &text) }
+ guard text.text_len > 0 else { return super.quickLook(with: event) }
// If we can get a font then we use the font. This should always work
// since we always have a primary font. The only scenario this doesn't
@@ -1230,8 +1277,8 @@ extension Ghostty {
}
// Ghostty coordinate system is top-left, convert to bottom-left for AppKit
- let pt = NSMakePoint(info.tl_px_x, frame.size.height - info.tl_px_y)
- let str = NSAttributedString.init(string: text, attributes: attributes)
+ let pt = NSMakePoint(text.tl_px_x, frame.size.height - text.tl_px_y)
+ let str = NSAttributedString.init(string: String(cString: text.text), attributes: attributes)
self.showDefinition(for: str, at: pt);
}
@@ -1250,8 +1297,8 @@ extension Ghostty {
// In this case, AppKit calls menu BEFORE calling any mouse events.
// If mouse capturing is enabled then we never show the context menu
// so that we can handle ctrl+left-click in the terminal app.
- guard let surface = self.surface else { return nil }
- if ghostty_surface_mouse_captured(surface) {
+ guard let surfaceModel else { return nil }
+ if surfaceModel.mouseCaptured {
return nil
}
@@ -1261,13 +1308,10 @@ extension Ghostty {
//
// Note this never sounds a right mouse up event but that's the
// same as normal right-click with capturing disabled from AppKit.
- let mods = Ghostty.ghosttyMods(event.modifierFlags)
- ghostty_surface_mouse_button(
- surface,
- GHOSTTY_MOUSE_PRESS,
- GHOSTTY_MOUSE_RIGHT,
- mods
- )
+ surfaceModel.sendMouseButton(.init(
+ action: .press,
+ button: .right,
+ mods: .init(nsFlags: event.modifierFlags)))
default:
return nil
@@ -1275,6 +1319,10 @@ extension Ghostty {
let menu = NSMenu()
+ // We just use a floating var so we can easily setup metadata on each item
+ // in a row without storing it all.
+ var item: NSMenuItem
+
// If we have a selection, add copy
if self.selectedRange().length > 0 {
menu.addItem(withTitle: "Copy", action: #selector(copy(_:)), keyEquivalent: "")
@@ -1282,16 +1330,23 @@ extension Ghostty {
menu.addItem(withTitle: "Paste", action: #selector(paste(_:)), keyEquivalent: "")
menu.addItem(.separator())
- menu.addItem(withTitle: "Split Right", action: #selector(splitRight(_:)), keyEquivalent: "")
- menu.addItem(withTitle: "Split Left", action: #selector(splitLeft(_:)), keyEquivalent: "")
- menu.addItem(withTitle: "Split Down", action: #selector(splitDown(_:)), keyEquivalent: "")
- menu.addItem(withTitle: "Split Up", action: #selector(splitUp(_:)), keyEquivalent: "")
+ item = menu.addItem(withTitle: "Split Right", action: #selector(splitRight(_:)), keyEquivalent: "")
+ item.setImageIfDesired(systemSymbolName: "rectangle.righthalf.inset.filled")
+ item = menu.addItem(withTitle: "Split Left", action: #selector(splitLeft(_:)), keyEquivalent: "")
+ item.setImageIfDesired(systemSymbolName: "rectangle.leadinghalf.inset.filled")
+ item = menu.addItem(withTitle: "Split Down", action: #selector(splitDown(_:)), keyEquivalent: "")
+ item.setImageIfDesired(systemSymbolName: "rectangle.bottomhalf.inset.filled")
+ item = menu.addItem(withTitle: "Split Up", action: #selector(splitUp(_:)), keyEquivalent: "")
+ item.setImageIfDesired(systemSymbolName: "rectangle.tophalf.inset.filled")
menu.addItem(.separator())
- menu.addItem(withTitle: "Reset Terminal", action: #selector(resetTerminal(_:)), keyEquivalent: "")
- menu.addItem(withTitle: "Toggle Terminal Inspector", action: #selector(toggleTerminalInspector(_:)), keyEquivalent: "")
+ item = menu.addItem(withTitle: "Reset Terminal", action: #selector(resetTerminal(_:)), keyEquivalent: "")
+ item.setImageIfDesired(systemSymbolName: "arrow.trianglehead.2.clockwise")
+ item = menu.addItem(withTitle: "Toggle Terminal Inspector", action: #selector(toggleTerminalInspector(_:)), keyEquivalent: "")
+ item.setImageIfDesired(systemSymbolName: "scope")
menu.addItem(.separator())
- menu.addItem(withTitle: "Change Title...", action: #selector(changeTitle(_:)), keyEquivalent: "")
+ item = menu.addItem(withTitle: "Change Title...", action: #selector(changeTitle(_:)), keyEquivalent: "")
+ item.setImageIfDesired(systemSymbolName: "pencil.line")
return menu
}
@@ -1396,13 +1451,29 @@ extension Ghostty {
trigger: nil
)
- UNUserNotificationCenter.current().add(request) { error in
+ // Note the callback may be executed on a background thread as documented
+ // so we need @MainActor since we're reading/writing view state.
+ UNUserNotificationCenter.current().add(request) { @MainActor error in
if let error = error {
AppDelegate.logger.error("Error scheduling user notification: \(error)")
return
}
+ // We need to keep track of this notification so we can remove it
+ // under certain circumstances
self.notificationIdentifiers.insert(uuid)
+
+ // If we're focused then we schedule to remove the notification
+ // after a few seconds. If we gain focus we automatically remove it
+ // in focusDidChange.
+ if (self.focused) {
+ Task { @MainActor [weak self] in
+ try await Task.sleep(for: .seconds(3))
+ self?.notificationIdentifiers.remove(uuid)
+ UNUserNotificationCenter.current()
+ .removeDeliveredNotifications(withIdentifiers: [uuid])
+ }
+ }
}
}
@@ -1439,6 +1510,35 @@ extension Ghostty {
self.windowAppearance = .init(ghosttyConfig: config)
}
}
+
+ // MARK: - Codable
+
+ enum CodingKeys: String, CodingKey {
+ case pwd
+ case uuid
+ }
+
+ required convenience init(from decoder: Decoder) throws {
+ // Decoding uses the global Ghostty app
+ guard let del = NSApplication.shared.delegate,
+ let appDel = del as? AppDelegate,
+ let app = appDel.ghostty.app else {
+ throw TerminalRestoreError.delegateInvalid
+ }
+
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ let uuid = UUID(uuidString: try container.decode(String.self, forKey: .uuid))
+ var config = Ghostty.SurfaceConfiguration()
+ config.workingDirectory = try container.decode(String?.self, forKey: .pwd)
+
+ self.init(app, baseConfig: config, uuid: uuid)
+ }
+
+ func encode(to encoder: Encoder) throws {
+ var container = encoder.container(keyedBy: CodingKeys.self)
+ try container.encode(pwd, forKey: .pwd)
+ try container.encode(uuid.uuidString, forKey: .uuid)
+ }
}
}
@@ -1460,9 +1560,10 @@ extension Ghostty.SurfaceView: NSTextInputClient {
// Get our range from the Ghostty API. There is a race condition between getting the
// range and actually using it since our selection may change but there isn't a good
// way I can think of to solve this for AppKit.
- var sel: ghostty_selection_s = ghostty_selection_s();
- guard ghostty_surface_selection_info(surface, &sel) else { return NSRange() }
- return NSRange(location: Int(sel.offset_start), length: Int(sel.offset_len))
+ var text = ghostty_text_s()
+ guard ghostty_surface_read_selection(surface, &text) else { return NSRange() }
+ defer { ghostty_surface_free_text(surface, &text) }
+ return NSRange(location: Int(text.offset_start), length: Int(text.offset_len))
}
func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) {
@@ -1500,7 +1601,6 @@ extension Ghostty.SurfaceView: NSTextInputClient {
func attributedSubstring(forProposedRange range: NSRange, actualRange: NSRangePointer?) -> NSAttributedString? {
// Ghostty.logger.warning("pressure substring range=\(range) selectedRange=\(self.selectedRange())")
guard let surface = self.surface else { return nil }
- guard ghostty_surface_has_selection(surface) else { return nil }
// If the range is empty then we don't need to return anything
guard range.length > 0 else { return nil }
@@ -1510,11 +1610,10 @@ extension Ghostty.SurfaceView: NSTextInputClient {
// bogus ranges I truly don't understand so we just always return the
// attributed string containing our selection which is... weird but works?
- // Get our selection. We cap it at 1MB for the purpose of this. This is
- // arbitrary. If this is a good reason to increase it I'm happy to.
- let v = String(unsafeUninitializedCapacity: 1000000) {
- Int(ghostty_surface_selection(surface, $0.baseAddress, UInt($0.count)))
- }
+ // Get our selection text
+ var text = ghostty_text_s()
+ guard ghostty_surface_read_selection(surface, &text) else { return nil }
+ defer { ghostty_surface_free_text(surface, &text) }
// If we can get a font then we use the font. This should always work
// since we always have a primary font. The only scenario this doesn't
@@ -1530,7 +1629,7 @@ extension Ghostty.SurfaceView: NSTextInputClient {
font.release()
}
- return .init(string: v, attributes: attributes)
+ return .init(string: String(cString: text.text), attributes: attributes)
}
func characterIndex(for point: NSPoint) -> Int {
@@ -1552,12 +1651,15 @@ extension Ghostty.SurfaceView: NSTextInputClient {
// point right now. I'm sure I'm missing something fundamental...
if range.length > 0 && range != self.selectedRange() {
// QuickLook
- var sel: ghostty_selection_s = ghostty_selection_s();
- if ghostty_surface_selection_info(surface, &sel) {
+ var text = ghostty_text_s()
+ if ghostty_surface_read_selection(surface, &text) {
// The -2/+2 here is subjective. QuickLook seems to offset the rectangle
// a bit and I think these small adjustments make it look more natural.
- x = sel.tl_px_x - 2;
- y = sel.tl_px_y + 2;
+ x = text.tl_px_x - 2;
+ y = text.tl_px_y + 2;
+
+ // Free our text
+ ghostty_surface_free_text(surface, &text)
} else {
ghostty_surface_ime_point(surface, &x, &y)
}
@@ -1580,7 +1682,7 @@ extension Ghostty.SurfaceView: NSTextInputClient {
func insertText(_ string: Any, replacementRange: NSRange) {
// We must have an associated event
guard NSApp.currentEvent != nil else { return }
- guard let surface = self.surface else { return }
+ guard let surfaceModel else { return }
// We want the string view of the any value
var chars = ""
@@ -1604,13 +1706,7 @@ extension Ghostty.SurfaceView: NSTextInputClient {
return
}
- let len = chars.utf8CString.count
- if (len == 0) { return }
-
- chars.withCString { ptr in
- // len includes the null terminator so we do len - 1
- ghostty_surface_text(surface, ptr, UInt(len - 1))
- }
+ surfaceModel.sendText(chars)
}
/// This function needs to exist for two reasons:
@@ -1683,14 +1779,13 @@ extension Ghostty.SurfaceView: NSServicesMenuRequestor {
) -> Bool {
guard let surface = self.surface else { return false }
- // We currently cap the maximum copy size to 1MB. iTerm2 I believe
- // caps theirs at 0.1MB (configurable) so this is probably reasonable.
- let v = String(unsafeUninitializedCapacity: 1000000) {
- Int(ghostty_surface_selection(surface, $0.baseAddress, UInt($0.count)))
- }
+ // Read the selection
+ var text = ghostty_text_s()
+ guard ghostty_surface_read_selection(surface, &text) else { return false }
+ defer { ghostty_surface_free_text(surface, &text) }
pboard.declareTypes([.string], owner: nil)
- pboard.setString(v, forType: .string)
+ pboard.setString(String(cString: text.text), forType: .string)
return true
}
@@ -1782,3 +1877,148 @@ extension Ghostty.SurfaceView {
return false
}
}
+
+// MARK: Accessibility
+
+extension Ghostty.SurfaceView {
+ /// Indicates that this view should be exposed to accessibility tools like VoiceOver.
+ /// By returning true, we make the terminal surface accessible to screen readers
+ /// and other assistive technologies.
+ override func isAccessibilityElement() -> Bool {
+ return true
+ }
+
+ /// Defines the accessibility role for this view, which helps assistive technologies
+ /// understand what kind of content this view contains and how users can interact with it.
+ override func accessibilityRole() -> NSAccessibility.Role? {
+ /// We use .textArea because the terminal surface is essentially an editable text area
+ /// where users can input commands and view output.
+ return .textArea
+ }
+
+ override func accessibilityHelp() -> String? {
+ return "Terminal content area"
+ }
+
+ override func accessibilityValue() -> Any? {
+ return cachedScreenContents.get()
+ }
+
+ /// Returns the range of text that is currently selected in the terminal.
+ /// This allows VoiceOver and other assistive technologies to understand
+ /// what text the user has selected.
+ override func accessibilitySelectedTextRange() -> NSRange {
+ return selectedRange()
+ }
+
+ /// Returns the currently selected text as a string.
+ /// This allows assistive technologies to read the selected content.
+ override func accessibilitySelectedText() -> String? {
+ guard let surface = self.surface else { return nil }
+
+ // Attempt to read the selection
+ var text = ghostty_text_s()
+ guard ghostty_surface_read_selection(surface, &text) else { return nil }
+ defer { ghostty_surface_free_text(surface, &text) }
+
+ let str = String(cString: text.text)
+ return str.isEmpty ? nil : str
+ }
+
+ /// Returns the number of characters in the terminal content.
+ /// This helps assistive technologies understand the size of the content.
+ override func accessibilityNumberOfCharacters() -> Int {
+ let content = cachedScreenContents.get()
+ return content.count
+ }
+
+ /// Returns the visible character range for the terminal.
+ /// For terminals, we typically show all content as visible.
+ override func accessibilityVisibleCharacterRange() -> NSRange {
+ let content = cachedScreenContents.get()
+ return NSRange(location: 0, length: content.count)
+ }
+
+ /// Returns the line number for a given character index.
+ /// This helps assistive technologies navigate by line.
+ override func accessibilityLine(for index: Int) -> Int {
+ let content = cachedScreenContents.get()
+ let substring = String(content.prefix(index))
+ return substring.components(separatedBy: .newlines).count - 1
+ }
+
+ /// Returns a substring for the given range.
+ /// This allows assistive technologies to read specific portions of the content.
+ override func accessibilityString(for range: NSRange) -> String? {
+ let content = cachedScreenContents.get()
+ guard let swiftRange = Range(range, in: content) else { return nil }
+ return String(content[swiftRange])
+ }
+
+ /// Returns an attributed string for the given range.
+ ///
+ /// Note: right now this only applies font information. One day it'd be nice to extend
+ /// this to copy styling information as well but we need to augment Ghostty core to
+ /// expose that.
+ ///
+ /// This provides styling information to assistive technologies.
+ override func accessibilityAttributedString(for range: NSRange) -> NSAttributedString? {
+ guard let surface = self.surface else { return nil }
+ guard let plainString = accessibilityString(for: range) else { return nil }
+
+ var attributes: [NSAttributedString.Key: Any] = [:]
+
+ // Try to get the font from the surface
+ if let fontRaw = ghostty_surface_quicklook_font(surface) {
+ let font = Unmanaged<CTFont>.fromOpaque(fontRaw)
+ attributes[.font] = font.takeUnretainedValue()
+ font.release()
+ }
+
+ return NSAttributedString(string: plainString, attributes: attributes)
+ }
+}
+
+/// Caches a value for some period of time, evicting it automatically when that time expires.
+/// We use this to cache our surface content. This probably should be extracted some day
+/// to a more generic helper.
+class CachedValue<T> {
+ private var value: T?
+ private let fetch: () -> T
+ private let duration: Duration
+ private var expiryTask: Task<Void, Never>?
+
+ init(duration: Duration, fetch: @escaping () -> T) {
+ self.duration = duration
+ self.fetch = fetch
+ }
+
+ deinit {
+ expiryTask?.cancel()
+ }
+
+ func get() -> T {
+ if let value {
+ return value
+ }
+
+ // We don't have a value (or it expired). Fetch and store.
+ let result = fetch()
+ let now = ContinuousClock.now
+ let expires = now + duration
+ self.value = result
+
+ // Schedule a task to clear the value
+ expiryTask = Task { [weak self] in
+ do {
+ try await Task.sleep(until: expires)
+ self?.value = nil
+ self?.expiryTask = nil
+ } catch {
+ // Task was cancelled, do nothing
+ }
+ }
+
+ return result
+ }
+}
diff --git a/macos/Sources/Ghostty/SurfaceView_UIKit.swift b/macos/Sources/Ghostty/SurfaceView_UIKit.swift
index 8d5b3038f..e88ec82e2 100644
--- a/macos/Sources/Ghostty/SurfaceView_UIKit.swift
+++ b/macos/Sources/Ghostty/SurfaceView_UIKit.swift
@@ -57,8 +57,10 @@ extension Ghostty {
// Setup our surface. This will also initialize all the terminal IO.
let surface_cfg = baseConfig ?? SurfaceConfiguration()
- var surface_cfg_c = surface_cfg.ghosttyConfig(view: self)
- guard let surface = ghostty_surface_new(app, &surface_cfg_c) else {
+ let surface = surface_cfg.withCValue(view: self) { surface_cfg_c in
+ ghostty_surface_new(app, &surface_cfg_c)
+ }
+ guard let surface = surface else {
// TODO
return
}
diff --git a/macos/Sources/Helpers/Xcode.swift b/macos/Sources/Helpers/AppInfo.swift
index 281bad18b..281bad18b 100644
--- a/macos/Sources/Helpers/Xcode.swift
+++ b/macos/Sources/Helpers/AppInfo.swift
diff --git a/macos/Sources/Helpers/ExpiringUndoManager.swift b/macos/Sources/Helpers/ExpiringUndoManager.swift
new file mode 100644
index 000000000..5fde0e870
--- /dev/null
+++ b/macos/Sources/Helpers/ExpiringUndoManager.swift
@@ -0,0 +1,148 @@
+/// An UndoManager subclass that supports registering undo operations that automatically expire after a specified duration.
+///
+/// This class extends the standard UndoManager to add time-based expiration for undo operations.
+/// When an undo operation expires, it is automatically removed from the undo stack and cannot be invoked.
+///
+/// Example usage:
+/// ```swift
+/// let undoManager = ExpiringUndoManager()
+/// undoManager.registerUndo(withTarget: myObject, expiresAfter: .seconds(30)) { target in
+/// // Undo operation that expires after 30 seconds
+/// target.restorePreviousState()
+/// }
+/// ```
+class ExpiringUndoManager: UndoManager {
+ /// The set of expiring targets so we can properly clean them up when removeAllActions
+ /// is called with the real target.
+ private lazy var expiringTargets: Set<ExpiringTarget> = []
+
+ /// Registers an undo operation that automatically expires after the specified duration.
+ ///
+ /// - Parameters:
+ /// - target: The target object for the undo operation. The undo operation will be removed
+ /// if this object is deallocated before the operation is invoked.
+ /// - duration: The duration after which the undo operation should expire and be removed from the undo stack.
+ /// - handler: The closure to execute when the undo operation is invoked. The closure receives
+ /// the target object as its parameter.
+ func registerUndo<TargetType: AnyObject>(
+ withTarget target: TargetType,
+ expiresAfter duration: Duration,
+ handler: @escaping (TargetType) -> Void
+ ) {
+ // Ignore instantly expiring undos
+ guard duration.timeInterval > 0 else { return }
+
+ // Ignore when undo registration is disabled. UndoManager still lets
+ // registration happen then cancels later but I was seeing some
+ // weird behavior with this so let's just guard on it.
+ guard self.isUndoRegistrationEnabled else { return }
+
+ let expiringTarget = ExpiringTarget(
+ target,
+ expiresAfter: duration,
+ in: self)
+ expiringTargets.insert(expiringTarget)
+
+ super.registerUndo(withTarget: expiringTarget) { [weak self] expiringTarget in
+ self?.expiringTargets.remove(expiringTarget)
+ guard let target = expiringTarget.target as? TargetType else { return }
+ handler(target)
+ }
+ }
+
+ /// Removes all undo and redo operations from the undo manager.
+ ///
+ /// This override ensures that all expiring targets are also cleared when
+ /// the undo manager is reset.
+ override func removeAllActions() {
+ super.removeAllActions()
+ expiringTargets = []
+ }
+
+ /// Removes all undo and redo operations involving the specified target.
+ ///
+ /// This override ensures that when actions are removed for a target, any associated
+ /// expiring targets are also properly cleaned up.
+ ///
+ /// - Parameter target: The target object whose actions should be removed.
+ override func removeAllActions(withTarget target: Any) {
+ // Call super to handle standard removal
+ super.removeAllActions(withTarget: target)
+
+ // If the target is an expiring target, remove it.
+ if let expiring = target as? ExpiringTarget {
+ expiringTargets.remove(expiring)
+ } else {
+ // Find and remove any ExpiringTarget instances that wrap this target.
+ expiringTargets
+ .filter { $0.target == nil || $0.target === (target as AnyObject) }
+ .forEach {
+ // Technically they'll always expire when they get deinitialized
+ // but we want to make sure it happens right now.
+ $0.expire()
+ expiringTargets.remove($0)
+ }
+ }
+ }
+}
+
+/// A target object for ExpiringUndoManager that removes itself from the
+/// undo manager after it expires.
+///
+/// This class acts as a proxy for the real target object in undo operations.
+/// It holds a weak reference to the actual target and automatically removes
+/// all associated undo operations when either:
+/// - The specified duration expires
+/// - The ExpiringTarget instance is deallocated
+/// - The expire() method is called manually
+private class ExpiringTarget {
+ /// The actual target object for the undo operation, held weakly to avoid retain cycles.
+ private(set) weak var target: AnyObject?
+
+ /// Timer that triggers expiration after the specified duration.
+ private var timer: Timer?
+
+ /// The undo manager from which to remove actions when this target expires.
+ private weak var undoManager: UndoManager?
+
+ /// Creates an expiring target that will automatically remove undo actions after the specified duration.
+ ///
+ /// - Parameters:
+ /// - target: The target object to hold weakly.
+ /// - duration: The time after which the target should expire.
+ /// - undoManager: The UndoManager from which to remove actions when expired.
+ init(_ target: AnyObject? = nil, expiresAfter duration: Duration, in undoManager: UndoManager) {
+ self.target = target
+ self.undoManager = undoManager
+ self.timer = Timer.scheduledTimer(
+ withTimeInterval: duration.timeInterval,
+ repeats: false) { [weak self] _ in
+ self?.expire()
+ }
+ }
+
+ /// Manually expires the target, removing all associated undo actions and invalidating the timer.
+ ///
+ /// This method is called automatically when the timer fires, but can also be called manually
+ /// to expire the target before the timer duration has elapsed.
+ func expire() {
+ target = nil
+ undoManager?.removeAllActions(withTarget: self)
+ timer?.invalidate()
+ timer = nil
+ }
+
+ deinit {
+ expire()
+ }
+}
+
+extension ExpiringTarget: Hashable, Equatable {
+ static func == (lhs: ExpiringTarget, rhs: ExpiringTarget) -> Bool {
+ return lhs === rhs
+ }
+
+ func hash(into hasher: inout Hasher) {
+ hasher.combine(ObjectIdentifier(self))
+ }
+}
diff --git a/macos/Sources/Helpers/Extensions/Array+Extension.swift b/macos/Sources/Helpers/Extensions/Array+Extension.swift
new file mode 100644
index 000000000..4e8e39918
--- /dev/null
+++ b/macos/Sources/Helpers/Extensions/Array+Extension.swift
@@ -0,0 +1,48 @@
+extension Array {
+ subscript(safe index: Int) -> Element? {
+ return indices.contains(index) ? self[index] : nil
+ }
+
+ /// Returns the index before i, with wraparound. Assumes i is a valid index.
+ func indexWrapping(before i: Int) -> Int {
+ if i == 0 {
+ return count - 1
+ }
+
+ return i - 1
+ }
+
+ /// Returns the index after i, with wraparound. Assumes i is a valid index.
+ func indexWrapping(after i: Int) -> Int {
+ if i == count - 1 {
+ return 0
+ }
+
+ return i + 1
+ }
+}
+
+extension Array where Element == String {
+ /// Executes a closure with an array of C string pointers.
+ func withCStrings<T>(_ body: ([UnsafePointer<Int8>?]) throws -> T) rethrows -> T {
+ // Handle empty array
+ if isEmpty {
+ return try body([])
+ }
+
+ // Recursive helper to process strings
+ func helper(index: Int, accumulated: [UnsafePointer<Int8>?], body: ([UnsafePointer<Int8>?]) throws -> T) rethrows -> T {
+ if index == count {
+ return try body(accumulated)
+ }
+
+ return try self[index].withCString { cStr in
+ var newAccumulated = accumulated
+ newAccumulated.append(cStr)
+ return try helper(index: index + 1, accumulated: newAccumulated, body: body)
+ }
+ }
+
+ return try helper(index: 0, accumulated: [], body: body)
+ }
+}
diff --git a/macos/Sources/Helpers/Extensions/Double+Extension.swift b/macos/Sources/Helpers/Extensions/Double+Extension.swift
new file mode 100644
index 000000000..8d1151bac
--- /dev/null
+++ b/macos/Sources/Helpers/Extensions/Double+Extension.swift
@@ -0,0 +1,5 @@
+extension Double {
+ func clamped(to range: ClosedRange<Double>) -> Double {
+ return Swift.min(Swift.max(self, range.lowerBound), range.upperBound)
+ }
+}
diff --git a/macos/Sources/Helpers/Extensions/Duration+Extension.swift b/macos/Sources/Helpers/Extensions/Duration+Extension.swift
new file mode 100644
index 000000000..43eca6b79
--- /dev/null
+++ b/macos/Sources/Helpers/Extensions/Duration+Extension.swift
@@ -0,0 +1,8 @@
+import Foundation
+
+extension Duration {
+ var timeInterval: TimeInterval {
+ return TimeInterval(self.components.seconds) +
+ TimeInterval(self.components.attoseconds) / 1_000_000_000_000_000_000
+ }
+}
diff --git a/macos/Sources/Helpers/EventModifiers+Extension.swift b/macos/Sources/Helpers/Extensions/EventModifiers+Extension.swift
index 8d379bd99..8d379bd99 100644
--- a/macos/Sources/Helpers/EventModifiers+Extension.swift
+++ b/macos/Sources/Helpers/Extensions/EventModifiers+Extension.swift
diff --git a/macos/Sources/Helpers/KeyboardShortcut+Extension.swift b/macos/Sources/Helpers/Extensions/KeyboardShortcut+Extension.swift
index 7891f12d7..7891f12d7 100644
--- a/macos/Sources/Helpers/KeyboardShortcut+Extension.swift
+++ b/macos/Sources/Helpers/Extensions/KeyboardShortcut+Extension.swift
diff --git a/macos/Sources/Helpers/NSAppearance+Extension.swift b/macos/Sources/Helpers/Extensions/NSAppearance+Extension.swift
index 28edb1a35..28edb1a35 100644
--- a/macos/Sources/Helpers/NSAppearance+Extension.swift
+++ b/macos/Sources/Helpers/Extensions/NSAppearance+Extension.swift
diff --git a/macos/Sources/Helpers/NSApplication+Extension.swift b/macos/Sources/Helpers/Extensions/NSApplication+Extension.swift
index d8e41523a..0bc79fb6a 100644
--- a/macos/Sources/Helpers/NSApplication+Extension.swift
+++ b/macos/Sources/Helpers/Extensions/NSApplication+Extension.swift
@@ -1,3 +1,4 @@
+import AppKit
import Cocoa
// MARK: Presentation Options
diff --git a/macos/Sources/Helpers/NSImage+Extension.swift b/macos/Sources/Helpers/Extensions/NSImage+Extension.swift
index 670148e27..670148e27 100644
--- a/macos/Sources/Helpers/NSImage+Extension.swift
+++ b/macos/Sources/Helpers/Extensions/NSImage+Extension.swift
diff --git a/macos/Sources/Helpers/Extensions/NSMenuItem+Extension.swift b/macos/Sources/Helpers/Extensions/NSMenuItem+Extension.swift
new file mode 100644
index 000000000..e512904ef
--- /dev/null
+++ b/macos/Sources/Helpers/Extensions/NSMenuItem+Extension.swift
@@ -0,0 +1,11 @@
+import AppKit
+
+extension NSMenuItem {
+ /// Sets the image property from a symbol if we want images on our menu items.
+ func setImageIfDesired(systemSymbolName symbol: String) {
+ // We only set on macOS 26 when icons on menu items became the norm.
+ if #available(macOS 26, *) {
+ image = NSImage(systemSymbolName: symbol, accessibilityDescription: title)
+ }
+ }
+}
diff --git a/macos/Sources/Helpers/NSPasteboard+Extension.swift b/macos/Sources/Helpers/Extensions/NSPasteboard+Extension.swift
index 11815fbc8..11815fbc8 100644
--- a/macos/Sources/Helpers/NSPasteboard+Extension.swift
+++ b/macos/Sources/Helpers/Extensions/NSPasteboard+Extension.swift
diff --git a/macos/Sources/Helpers/NSScreen+Extension.swift b/macos/Sources/Helpers/Extensions/NSScreen+Extension.swift
index 675e0b2ec..675e0b2ec 100644
--- a/macos/Sources/Helpers/NSScreen+Extension.swift
+++ b/macos/Sources/Helpers/Extensions/NSScreen+Extension.swift
diff --git a/macos/Sources/Helpers/Extensions/NSView+Extension.swift b/macos/Sources/Helpers/Extensions/NSView+Extension.swift
new file mode 100644
index 000000000..fb209e4ac
--- /dev/null
+++ b/macos/Sources/Helpers/Extensions/NSView+Extension.swift
@@ -0,0 +1,221 @@
+import AppKit
+import SwiftUI
+
+extension NSView {
+ /// Returns true if this view is currently in the responder chain
+ var isInResponderChain: Bool {
+ var responder = window?.firstResponder
+ while let currentResponder = responder {
+ if currentResponder === self {
+ return true
+ }
+ responder = currentResponder.nextResponder
+ }
+
+ return false
+ }
+}
+
+// MARK: Screenshot
+
+extension NSView {
+ /// Take a screenshot of just this view.
+ func screenshot() -> NSImage? {
+ guard let bitmapRep = bitmapImageRepForCachingDisplay(in: bounds) else { return nil }
+ cacheDisplay(in: bounds, to: bitmapRep)
+ let image = NSImage(size: bounds.size)
+ image.addRepresentation(bitmapRep)
+ return image
+ }
+
+ func screenshot() -> Image? {
+ guard let nsImage: NSImage = self.screenshot() else { return nil }
+ return Image(nsImage: nsImage)
+ }
+}
+
+// MARK: View Traversal and Search
+
+extension NSView {
+ /// Returns the absolute root view by walking up the superview chain.
+ var rootView: NSView {
+ var root: NSView = self
+ while let superview = root.superview {
+ root = superview
+ }
+ return root
+ }
+
+ /// Checks if a view contains another view in its hierarchy.
+ func contains(_ view: NSView) -> Bool {
+ if self == view {
+ return true
+ }
+
+ for subview in subviews {
+ if subview.contains(view) {
+ return true
+ }
+ }
+
+ return false
+ }
+
+ /// Checks if the view contains the given class in its hierarchy.
+ func contains(className name: String) -> Bool {
+ if String(describing: type(of: self)) == name {
+ return true
+ }
+
+ for subview in subviews {
+ if subview.contains(className: name) {
+ return true
+ }
+ }
+
+ return false
+ }
+
+ /// Finds the superview with the given class name.
+ func firstSuperview(withClassName name: String) -> NSView? {
+ guard let superview else { return nil }
+ if String(describing: type(of: superview)) == name {
+ return superview
+ }
+
+ return superview.firstSuperview(withClassName: name)
+ }
+
+ /// Recursively finds and returns the first descendant view that has the given class name.
+ func firstDescendant(withClassName name: String) -> NSView? {
+ for subview in subviews {
+ if String(describing: type(of: subview)) == name {
+ return subview
+ } else if let found = subview.firstDescendant(withClassName: name) {
+ return found
+ }
+ }
+
+ return nil
+ }
+
+ /// Recursively finds and returns descendant views that have the given class name.
+ func descendants(withClassName name: String) -> [NSView] {
+ var result = [NSView]()
+
+ for subview in subviews {
+ if String(describing: type(of: subview)) == name {
+ result.append(subview)
+ }
+
+ result += subview.descendants(withClassName: name)
+ }
+
+ return result
+ }
+
+ /// Recursively finds and returns the first descendant view that has the given identifier.
+ func firstDescendant(withID id: String) -> NSView? {
+ for subview in subviews {
+ if subview.identifier == NSUserInterfaceItemIdentifier(id) {
+ return subview
+ } else if let found = subview.firstDescendant(withID: id) {
+ return found
+ }
+ }
+
+ return nil
+ }
+
+ /// Finds and returns the first view with the given class name starting from the absolute root of the view hierarchy.
+ /// This includes private views like title bar views.
+ func firstViewFromRoot(withClassName name: String) -> NSView? {
+ let root = rootView
+
+ // Check if the root view itself matches
+ if String(describing: type(of: root)) == name {
+ return root
+ }
+
+ // Otherwise search descendants
+ return root.firstDescendant(withClassName: name)
+ }
+}
+
+// MARK: Debug
+
+extension NSView {
+ /// Prints the view hierarchy from the root in a tree-like ASCII format.
+ ///
+ /// I need this because the "Capture View Hierarchy" was broken under some scenarios in
+ /// Xcode 26 (FB17912569). But, I kept it around because it might be useful to print out
+ /// the view hierarchy without halting the program.
+ func printViewHierarchy() {
+ let root = rootView
+ print("View Hierarchy from Root:")
+ print(root.viewHierarchyDescription())
+ }
+
+ /// Returns a string representation of the view hierarchy in a tree-like format.
+ func viewHierarchyDescription(indent: String = "", isLast: Bool = true) -> String {
+ var result = ""
+
+ // Add the tree branch characters
+ result += indent
+ if !indent.isEmpty {
+ result += isLast ? "└── " : "├── "
+ }
+
+ // Add the class name and optional identifier
+ let className = String(describing: type(of: self))
+ result += className
+
+ // Add identifier if present
+ if let identifier = self.identifier {
+ result += " (id: \(identifier.rawValue))"
+ }
+
+ // Add frame info
+ result += " [frame: \(frame)]"
+
+ // Add visual properties
+ var properties: [String] = []
+
+ // Hidden status
+ if isHidden {
+ properties.append("hidden")
+ }
+
+ // Opaque status
+ properties.append(isOpaque ? "opaque" : "transparent")
+
+ // Layer backing
+ if wantsLayer {
+ properties.append("layer-backed")
+ if let bgColor = layer?.backgroundColor {
+ let color = NSColor(cgColor: bgColor)
+ if let rgb = color?.usingColorSpace(.deviceRGB) {
+ properties.append(String(format: "bg:rgba(%.0f,%.0f,%.0f,%.2f)",
+ rgb.redComponent * 255,
+ rgb.greenComponent * 255,
+ rgb.blueComponent * 255,
+ rgb.alphaComponent))
+ } else {
+ properties.append("bg:\(bgColor)")
+ }
+ }
+ }
+
+ result += " [\(properties.joined(separator: ", "))]"
+ result += "\n"
+
+ // Process subviews
+ for (index, subview) in subviews.enumerated() {
+ let isLastSubview = index == subviews.count - 1
+ let newIndent = indent + (isLast ? " " : "│ ")
+ result += subview.viewHierarchyDescription(indent: newIndent, isLast: isLastSubview)
+ }
+
+ return result
+ }
+}
diff --git a/macos/Sources/Helpers/NSWindow+Extension.swift b/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift
index 06a9fa4e0..f9ed364aa 100644
--- a/macos/Sources/Helpers/NSWindow+Extension.swift
+++ b/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift
@@ -9,4 +9,10 @@ extension NSWindow {
guard windowNumber > 0 else { return nil }
return CGWindowID(windowNumber)
}
+
+ /// True if this is the first window in the tab group.
+ var isFirstWindowInTabGroup: Bool {
+ guard let firstWindow = tabGroup?.windows.first else { return true }
+ return firstWindow === self
+ }
}
diff --git a/macos/Sources/Helpers/OSColor+Extension.swift b/macos/Sources/Helpers/Extensions/OSColor+Extension.swift
index 54b3e1fab..54b3e1fab 100644
--- a/macos/Sources/Helpers/OSColor+Extension.swift
+++ b/macos/Sources/Helpers/Extensions/OSColor+Extension.swift
diff --git a/macos/Sources/Helpers/Extensions/Optional+Extension.swift b/macos/Sources/Helpers/Extensions/Optional+Extension.swift
new file mode 100644
index 000000000..a844c0fe9
--- /dev/null
+++ b/macos/Sources/Helpers/Extensions/Optional+Extension.swift
@@ -0,0 +1,10 @@
+extension Optional where Wrapped == String {
+ /// Executes a closure with a C string pointer, handling nil gracefully.
+ func withCString<T>(_ body: (UnsafePointer<Int8>?) throws -> T) rethrows -> T {
+ if let string = self {
+ return try string.withCString(body)
+ } else {
+ return try body(nil)
+ }
+ }
+}
diff --git a/macos/Sources/Helpers/String+Extension.swift b/macos/Sources/Helpers/Extensions/String+Extension.swift
index 0c1c4fe91..0c1c4fe91 100644
--- a/macos/Sources/Helpers/String+Extension.swift
+++ b/macos/Sources/Helpers/Extensions/String+Extension.swift
diff --git a/macos/Sources/Helpers/Extensions/UndoManager+Extension.swift b/macos/Sources/Helpers/Extensions/UndoManager+Extension.swift
new file mode 100644
index 000000000..6c7c1e9f1
--- /dev/null
+++ b/macos/Sources/Helpers/Extensions/UndoManager+Extension.swift
@@ -0,0 +1,20 @@
+import Foundation
+
+extension UndoManager {
+ /// A Boolean value that indicates whether the undo manager is currently performing
+ /// either an undo or redo operation.
+ var isUndoingOrRedoing: Bool {
+ isUndoing || isRedoing
+ }
+
+ /// Temporarily disables undo registration while executing the provided handler.
+ ///
+ /// This method provides a convenient way to perform operations without recording them
+ /// in the undo stack. It ensures that undo registration is properly re-enabled even
+ /// if the handler throws an error.
+ func disableUndoRegistration(handler: () -> Void) {
+ disableUndoRegistration()
+ handler()
+ enableUndoRegistration()
+ }
+}
diff --git a/macos/Sources/Helpers/View+Extension.swift b/macos/Sources/Helpers/Extensions/View+Extension.swift
index fb6e0c20f..fb6e0c20f 100644
--- a/macos/Sources/Helpers/View+Extension.swift
+++ b/macos/Sources/Helpers/Extensions/View+Extension.swift
diff --git a/macos/Sources/Helpers/Fullscreen.swift b/macos/Sources/Helpers/Fullscreen.swift
index 6094bf844..f3940a9aa 100644
--- a/macos/Sources/Helpers/Fullscreen.swift
+++ b/macos/Sources/Helpers/Fullscreen.swift
@@ -45,10 +45,6 @@ protocol FullscreenDelegate: AnyObject {
func fullscreenDidChange()
}
-extension FullscreenDelegate {
- func fullscreenDidChange() {}
-}
-
/// The base class for fullscreen implementations, cannot be used as a FullscreenStyle on its own.
class FullscreenBase {
let window: NSWindow
@@ -78,10 +74,12 @@ class FullscreenBase {
}
@objc private func didEnterFullScreenNotification(_ notification: Notification) {
+ NotificationCenter.default.post(name: .fullscreenDidEnter, object: self)
delegate?.fullscreenDidChange()
}
@objc private func didExitFullScreenNotification(_ notification: Notification) {
+ NotificationCenter.default.post(name: .fullscreenDidExit, object: self)
delegate?.fullscreenDidChange()
}
}
@@ -150,6 +148,26 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle {
private var savedState: SavedState?
+ required init?(_ window: NSWindow) {
+ super.init(window)
+
+ NotificationCenter.default.addObserver(
+ self,
+ selector: #selector(windowWillCloseNotification),
+ name: NSWindow.willCloseNotification,
+ object: window)
+ }
+
+ deinit {
+ NotificationCenter.default.removeObserver(self)
+ }
+
+ @objc private func windowWillCloseNotification(_ notification: Notification) {
+ // When the window closes we need to explicitly exit non-native fullscreen
+ // otherwise some state like the menu bar can remain hidden.
+ exit()
+ }
+
func enter() {
// If we are in fullscreen we don't do it again.
guard !isFullscreen else { return }
@@ -218,6 +236,7 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle {
self.window.makeFirstResponder(firstResponder)
}
+ NotificationCenter.default.post(name: .fullscreenDidEnter, object: self)
self.delegate?.fullscreenDidChange()
}
}
@@ -246,13 +265,24 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle {
window.styleMask = savedState.styleMask
window.setFrame(window.frameRect(forContentRect: savedState.contentFrame), display: true)
- // This is a hack that I want to remove from this but for now, we need to
- // fix up the titlebar tabs here before we do everything below.
- if let window = window as? TerminalWindow,
- window.titlebarTabs {
- window.titlebarTabs = true
+ // Removing the "titled" style also derefs all our accessory view controllers
+ // so we need to restore those.
+ for c in savedState.titlebarAccessoryViewControllers {
+ // Restoring the tab bar causes all sorts of problems. Its best to just ignore it,
+ // even though this is kind of a hack.
+ if let window = window as? TerminalWindow, window.isTabBar(c) {
+ continue
+ }
+
+ if window.titlebarAccessoryViewControllers.firstIndex(of: c) == nil {
+ window.addTitlebarAccessoryViewController(c)
+ }
}
+ // Removing "titled" also clears our toolbar
+ window.toolbar = savedState.toolbar
+ window.toolbarStyle = savedState.toolbarStyle
+
// If the window was previously in a tab group that isn't empty now,
// we re-add it. We have to do this because our process of doing non-native
// fullscreen removes the window from the tab group.
@@ -283,6 +313,7 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle {
window.makeKeyAndOrderFront(nil)
// Notify the delegate
+ NotificationCenter.default.post(name: .fullscreenDidExit, object: self)
self.delegate?.fullscreenDidChange()
}
@@ -360,6 +391,9 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle {
let tabGroupIndex: Int?
let contentFrame: NSRect
let styleMask: NSWindow.StyleMask
+ let toolbar: NSToolbar?
+ let toolbarStyle: NSWindow.ToolbarStyle
+ let titlebarAccessoryViewControllers: [NSTitlebarAccessoryViewController]
let dock: Bool
let menu: Bool
@@ -371,6 +405,9 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle {
self.tabGroupIndex = window.tabGroup?.windows.firstIndex(of: window)
self.contentFrame = window.convertToScreen(contentView.frame)
self.styleMask = window.styleMask
+ self.toolbar = window.toolbar
+ self.toolbarStyle = window.toolbarStyle
+ self.titlebarAccessoryViewControllers = window.titlebarAccessoryViewControllers
self.dock = window.screen?.hasDock ?? false
if let cgWindowId = window.cgWindowId {
@@ -402,3 +439,8 @@ class NonNativeFullscreenVisibleMenu: NonNativeFullscreen {
class NonNativeFullscreenPaddedNotch: NonNativeFullscreen {
override var properties: Properties { Properties(paddedNotch: true) }
}
+
+extension Notification.Name {
+ static let fullscreenDidEnter = Notification.Name("com.mitchellh.fullscreenDidEnter")
+ static let fullscreenDidExit = Notification.Name("com.mitchellh.fullscreenDidExit")
+}
diff --git a/macos/Sources/Helpers/NSView+Extension.swift b/macos/Sources/Helpers/NSView+Extension.swift
deleted file mode 100644
index b9234a49a..000000000
--- a/macos/Sources/Helpers/NSView+Extension.swift
+++ /dev/null
@@ -1,44 +0,0 @@
-import AppKit
-
-extension NSView {
- /// Recursively finds and returns the first descendant view that has the given class name.
- func firstDescendant(withClassName name: String) -> NSView? {
- for subview in subviews {
- if String(describing: type(of: subview)) == name {
- return subview
- } else if let found = subview.firstDescendant(withClassName: name) {
- return found
- }
- }
-
- return nil
- }
-
- /// Recursively finds and returns descendant views that have the given class name.
- func descendants(withClassName name: String) -> [NSView] {
- var result = [NSView]()
-
- for subview in subviews {
- if String(describing: type(of: subview)) == name {
- result.append(subview)
- }
-
- result += subview.descendants(withClassName: name)
- }
-
- return result
- }
-
- /// Recursively finds and returns the first descendant view that has the given identifier.
- func firstDescendant(withID id: String) -> NSView? {
- for subview in subviews {
- if subview.identifier == NSUserInterfaceItemIdentifier(id) {
- return subview
- } else if let found = subview.firstDescendant(withID: id) {
- return found
- }
- }
-
- return nil
- }
-}
diff --git a/macos/Sources/Helpers/PermissionRequest.swift b/macos/Sources/Helpers/PermissionRequest.swift
new file mode 100644
index 000000000..9c16c7163
--- /dev/null
+++ b/macos/Sources/Helpers/PermissionRequest.swift
@@ -0,0 +1,213 @@
+import AppKit
+import Foundation
+
+/// Displays a permission request dialog with optional caching of user decisions
+class PermissionRequest {
+ /// Specifies how long a permission decision should be cached
+ enum AllowDuration {
+ case once
+ case forever
+ case duration(Duration)
+ }
+
+ /// Shows a permission request dialog with customizable caching behavior
+ /// - Parameters:
+ /// - key: Unique identifier for storing/retrieving cached decisions in UserDefaults
+ /// - message: The message to display in the alert dialog
+ /// - allowText: Custom text for the allow button (defaults to "Allow")
+ /// - allowDuration: If provided, automatically cache "Allow" responses for this duration
+ /// - rememberDuration: If provided, shows a checkbox to remember the decision for this duration
+ /// - window: If provided, shows the alert as a sheet attached to this window
+ /// - completion: Called with the user's decision (true for allow, false for deny)
+ ///
+ /// Caching behavior:
+ /// - If rememberDuration is provided and user checks "Remember my decision", both allow/deny are cached for that duration
+ /// - If allowDuration is provided and user selects allow (without checkbox), decision is cached for that duration
+ /// - Cached decisions are automatically returned without showing the dialog
+ @MainActor
+ static func show(
+ _ key: String,
+ message: String,
+ informative: String = "",
+ allowText: String = "Allow",
+ allowDuration: AllowDuration = .once,
+ rememberDuration: Duration? = .seconds(86400),
+ window: NSWindow? = nil,
+ completion: @escaping (Bool) -> Void
+ ) {
+ // Check if we have a stored decision that hasn't expired
+ if let storedResult = getStoredResult(for: key) {
+ completion(storedResult)
+ return
+ }
+
+ let alert = NSAlert()
+ alert.messageText = message
+ alert.informativeText = informative
+ alert.alertStyle = .informational
+
+ // Add buttons (they appear in reverse order)
+ alert.addButton(withTitle: allowText)
+ alert.addButton(withTitle: "Don't Allow")
+
+ // Create checkbox for remembering if duration is provided
+ var checkbox: NSButton?
+ if let rememberDuration = rememberDuration {
+ let checkboxTitle = formatRememberText(for: rememberDuration)
+ checkbox = NSButton(
+ checkboxWithTitle: checkboxTitle,
+ target: nil,
+ action: nil)
+ checkbox!.state = .off
+
+ // Set checkbox as accessory view
+ alert.accessoryView = checkbox
+ }
+
+ // Show the alert
+ if let window = window {
+ alert.beginSheetModal(for: window) { response in
+ handleResponse(response, rememberDecision: checkbox?.state == .on, key: key, allowDuration: allowDuration, rememberDuration: rememberDuration, completion: completion)
+ }
+ } else {
+ let response = alert.runModal()
+ handleResponse(response, rememberDecision: checkbox?.state == .on, key: key, allowDuration: allowDuration, rememberDuration: rememberDuration, completion: completion)
+ }
+ }
+
+ /// Handles the alert response and processes caching logic
+ /// - Parameters:
+ /// - response: The alert response from the user
+ /// - rememberDecision: Whether the remember checkbox was checked
+ /// - key: The UserDefaults key for caching
+ /// - allowDuration: Optional duration for auto-caching allow responses
+ /// - rememberDuration: Optional duration for the remember checkbox
+ /// - completion: Completion handler to call with the result
+ private static func handleResponse(
+ _ response: NSApplication.ModalResponse,
+ rememberDecision: Bool,
+ key: String,
+ allowDuration: AllowDuration,
+ rememberDuration: Duration?,
+ completion: @escaping (Bool) -> Void) {
+
+ let result: Bool
+ switch response {
+ case .alertFirstButtonReturn: // Allow
+ result = true
+ case .alertSecondButtonReturn: // Don't Allow
+ result = false
+ default:
+ result = false
+ }
+
+ // Store the result if checkbox is checked or if "Allow" was selected and allowDuration is set
+ if rememberDecision, let rememberDuration = rememberDuration {
+ storeResult(result, for: key, duration: rememberDuration)
+ } else if result {
+ switch allowDuration {
+ case .once:
+ // Don't store anything for once
+ break
+ case .forever:
+ // Store for a very long time (100 years). When the bug comes in that
+ // 100 years has passed and their forever permission expired I'll be
+ // dead so it won't be my problem.
+ storeResult(result, for: key, duration: .seconds(3153600000))
+ case .duration(let duration):
+ storeResult(result, for: key, duration: duration)
+ }
+ }
+
+ completion(result)
+ }
+
+ /// Retrieves a cached permission decision if it hasn't expired
+ /// - Parameter key: The UserDefaults key to check
+ /// - Returns: The cached decision, or nil if no valid cached decision exists
+ private static func getStoredResult(for key: String) -> Bool? {
+ let userDefaults = UserDefaults.standard
+ guard let data = userDefaults.data(forKey: key),
+ let storedPermission = try? NSKeyedUnarchiver.unarchivedObject(
+ ofClass: StoredPermission.self, from: data) else {
+ return nil
+ }
+
+ if Date() > storedPermission.expiry {
+ // Decision has expired, remove stored value
+ userDefaults.removeObject(forKey: key)
+ return nil
+ }
+
+ return storedPermission.result
+ }
+
+ /// Stores a permission decision in UserDefaults with an expiration date
+ /// - Parameters:
+ /// - result: The permission decision to store
+ /// - key: The UserDefaults key to store under
+ /// - duration: How long the decision should be cached
+ private static func storeResult(_ result: Bool, for key: String, duration: Duration) {
+ let expiryDate = Date().addingTimeInterval(duration.timeInterval)
+ let storedPermission = StoredPermission(result: result, expiry: expiryDate)
+ if let data = try? NSKeyedArchiver.archivedData(withRootObject: storedPermission, requiringSecureCoding: true) {
+ let userDefaults = UserDefaults.standard
+ userDefaults.set(data, forKey: key)
+ }
+ }
+
+ /// Formats the remember checkbox text based on the duration
+ /// - Parameter duration: The duration to format
+ /// - Returns: A human-readable string for the checkbox
+ private static func formatRememberText(for duration: Duration) -> String {
+ let seconds = duration.timeInterval
+
+ // Warning: this probably isn't localization friendly at all so we're
+ // going to have to redo this for that.
+ switch seconds {
+ case 0..<60:
+ return "Remember my decision for \(Int(seconds)) seconds"
+ case 60..<3600:
+ let minutes = Int(seconds / 60)
+ return "Remember my decision for \(minutes) minute\(minutes == 1 ? "" : "s")"
+ case 3600..<86400:
+ let hours = Int(seconds / 3600)
+ return "Remember my decision for \(hours) hour\(hours == 1 ? "" : "s")"
+ case 86400:
+ return "Remember my decision for one day"
+ default:
+ let days = Int(seconds / 86400)
+ return "Remember my decision for \(days) day\(days == 1 ? "" : "s")"
+ }
+ }
+
+ /// Internal class for storing permission decisions with expiration dates in UserDefaults
+ /// Conforms to NSSecureCoding for safe archiving/unarchiving
+ @objc(StoredPermission)
+ private class StoredPermission: NSObject, NSSecureCoding {
+ static var supportsSecureCoding: Bool = true
+
+ let result: Bool
+ let expiry: Date
+
+ init(result: Bool, expiry: Date) {
+ self.result = result
+ self.expiry = expiry
+ super.init()
+ }
+
+ required init?(coder: NSCoder) {
+ self.result = coder.decodeBool(forKey: "result")
+ guard let expiry = coder.decodeObject(of: NSDate.self, forKey: "expiry") as? Date else {
+ return nil
+ }
+ self.expiry = expiry
+ super.init()
+ }
+
+ func encode(with coder: NSCoder) {
+ coder.encode(result, forKey: "result")
+ coder.encode(expiry, forKey: "expiry")
+ }
+ }
+}
diff --git a/macos/Sources/Helpers/TabGroupCloseCoordinator.swift b/macos/Sources/Helpers/TabGroupCloseCoordinator.swift
new file mode 100644
index 000000000..ca41bf89c
--- /dev/null
+++ b/macos/Sources/Helpers/TabGroupCloseCoordinator.swift
@@ -0,0 +1,124 @@
+import AppKit
+
+/// Coordinates close operations for windows that are part of a tab group.
+///
+/// This coordinator helps distinguish between closing a single tab versus closing
+/// an entire window (with all its tabs). When macOS native tabs are used, close
+/// operations can be ambiguous - this coordinator tracks close requests across
+/// multiple windows in a tab group to determine the user's intent.
+class TabGroupCloseCoordinator {
+ /// The scope of a close operation.
+ enum CloseScope {
+ case tab
+ case window
+ }
+
+ /// Protocol that window controllers must implement to use the coordinator.
+ protocol Controller {
+ /// The tab group close coordinator instance for this controller.
+ var tabGroupCloseCoordinator: TabGroupCloseCoordinator { get }
+ }
+
+ /// Callback type for close operations.
+ typealias Callback = (CloseScope) -> Void
+
+ // We use weak vars and ObjectIdentifiers below because we don't want to
+ // create any strong reference cycles during coordination.
+
+ /// The tab group being coordinated. Weak reference to avoid cycles.
+ private weak var tabGroup: NSWindowTabGroup?
+
+ /// Map of window identifiers to their close callbacks.
+ private var closeRequests: [ObjectIdentifier: Callback] = [:]
+
+ /// Timer used to debounce close requests and determine intent.
+ private var debounceTimer: Timer?
+
+ deinit {
+ trigger(.tab)
+ }
+
+ /// Call this from the windowShouldClose override in order to track whether
+ /// a window close event is from a tab or a window. If this window already
+ /// requested a close then only the latest will be called.
+ func windowShouldClose(
+ _ window: NSWindow,
+ callback: @escaping Callback
+ ) {
+ // If this window isn't part of a tab group we assume its a window
+ // close for the window and let our timer keep running for the rest.
+ guard let tabGroup = window.tabGroup else {
+ callback(.window)
+ return
+ }
+
+ // Forward to the proper coordinator
+ if let firstController = tabGroup.windows.first?.windowController as? Controller,
+ firstController.tabGroupCloseCoordinator !== self {
+ let coordinator = firstController.tabGroupCloseCoordinator
+ coordinator.windowShouldClose(window, callback: callback)
+ return
+ }
+
+ // If our tab group is nil then we either are seeing this for the first
+ // time or our weak ref expired and we should fire our callbacks.
+ if self.tabGroup == nil {
+ self.tabGroup = tabGroup
+ debounceTimer?.fire()
+ debounceTimer = nil
+ }
+
+ // No matter what, we cancel our debounce and restart this. This opens
+ // us up to a DoS if close requests are looped but this would only
+ // happen in hostile scenarios that are self-inflicted.
+ debounceTimer?.invalidate()
+ debounceTimer = nil
+
+ // If this tab group doesn't match then I don't really know what to
+ // do. This shouldn't happen. So we just assume it's a tab close
+ // and trigger the rest. No right answer here as far as I know.
+ if self.tabGroup != tabGroup {
+ callback(.tab)
+ trigger(.tab)
+ return
+ }
+
+ // Add the request
+ closeRequests[ObjectIdentifier(window)] = callback
+
+ // If close requests matches all our windows then we are done.
+ if closeRequests.count == tabGroup.windows.count {
+ let allWindows = Set(tabGroup.windows.map { ObjectIdentifier($0) })
+ if Set(closeRequests.keys) == allWindows {
+ trigger(.window)
+ return
+ }
+ }
+
+ // Setup our new timer
+ debounceTimer = Timer.scheduledTimer(
+ withTimeInterval: Duration.milliseconds(100).timeInterval,
+ repeats: false
+ ) { [weak self] _ in
+ self?.trigger(.tab)
+ }
+ }
+
+ /// Triggers all pending close callbacks with the given scope.
+ ///
+ /// This method is called when the coordinator has determined the user's intent
+ /// (either closing a tab or the entire window). It executes all pending callbacks
+ /// and resets the coordinator's state.
+ ///
+ /// - Parameter scope: The determined scope of the close operation.
+ private func trigger(_ scope: CloseScope) {
+ // Reset our state
+ tabGroup = nil
+ debounceTimer?.invalidate()
+ debounceTimer = nil
+
+ // Trigger all of our callbacks
+ closeRequests.forEach { $0.value(scope) }
+ closeRequests = [:]
+ }
+}
diff --git a/nix/devShell.nix b/nix/devShell.nix
index b87c23dd1..f4ea62235 100644
--- a/nix/devShell.nix
+++ b/nix/devShell.nix
@@ -16,7 +16,7 @@
python3,
qemu,
scdoc,
- snapcraft,
+ # snapcraft,
valgrind,
#, vulkan-loader # unused
vttest,
@@ -134,7 +134,7 @@ in
appstream
flatpak-builder
gdb
- snapcraft
+ # snapcraft
valgrind
wraptest
diff --git a/pkg/README.md b/pkg/README.md
index 1d6f9f6eb..fddc4b3db 100644
--- a/pkg/README.md
+++ b/pkg/README.md
@@ -12,7 +12,7 @@ paste them into your project.
the Ghostty project. This license does not apply to the rest of the
Ghostty project.**
-Copyright © 2024 Mitchell Hashimoto
+Copyright © 2024 Mitchell Hashimoto, Ghostty contributors
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the “Software”), to deal in
diff --git a/pkg/apple-sdk/build.zig b/pkg/apple-sdk/build.zig
index 1be733dd6..18a6c0968 100644
--- a/pkg/apple-sdk/build.zig
+++ b/pkg/apple-sdk/build.zig
@@ -7,12 +7,17 @@ pub fn build(b: *std.Build) !void {
_ = optimize;
}
-/// Add the SDK framework, include, and library paths to the given module.
-/// The module target is used to determine the SDK to use so it must have
-/// a resolved target.
-pub fn addPaths(b: *std.Build, m: *std.Build.Module) !void {
+/// Setup the step to point to the proper Apple SDK for libc and
+/// frameworks. This expects and relies on the native SDK being
+/// installed on the system. Ghostty doesn't support cross-compilation
+/// for Apple platforms.
+pub fn addPaths(
+ b: *std.Build,
+ step: *std.Build.Step.Compile,
+) !void {
// The cache. This always uses b.allocator and never frees memory
- // (which is idiomatic for a Zig build exe).
+ // (which is idiomatic for a Zig build exe). We cache the libc txt
+ // file we create because it is expensive to generate (subprocesses).
const Cache = struct {
const Key = struct {
arch: std.Target.Cpu.Arch,
@@ -20,27 +25,72 @@ pub fn addPaths(b: *std.Build, m: *std.Build.Module) !void {
abi: std.Target.Abi,
};
- var map: std.AutoHashMapUnmanaged(Key, ?[]const u8) = .{};
+ var map: std.AutoHashMapUnmanaged(Key, ?struct {
+ libc: std.Build.LazyPath,
+ framework: []const u8,
+ system_include: []const u8,
+ library: []const u8,
+ }) = .{};
};
- const target = m.resolved_target.?.result;
+ const target = step.rootModuleTarget();
const gop = try Cache.map.getOrPut(b.allocator, .{
.arch = target.cpu.arch,
.os = target.os.tag,
.abi = target.abi,
});
- // This executes `xcrun` to get the SDK path. We don't want to execute
- // this multiple times so we cache the value.
if (!gop.found_existing) {
- gop.value_ptr.* = std.zig.system.darwin.getSdk(
- b.allocator,
- m.resolved_target.?.result,
- );
+ // Detect our SDK using the "findNative" Zig stdlib function.
+ // This is really important because it forces using `xcrun` to
+ // find the SDK path.
+ const libc = try std.zig.LibCInstallation.findNative(.{
+ .allocator = b.allocator,
+ .target = step.rootModuleTarget(),
+ .verbose = false,
+ });
+
+ // Render the file compatible with the `--libc` Zig flag.
+ var list: std.ArrayList(u8) = .init(b.allocator);
+ defer list.deinit();
+ try libc.render(list.writer());
+
+ // Create a temporary file to store the libc path because
+ // `--libc` expects a file path.
+ const wf = b.addWriteFiles();
+ const path = wf.add("libc.txt", list.items);
+
+ // Determine our framework path. Zig has a bug where it doesn't
+ // parse this from the libc txt file for `-framework` flags:
+ // https://github.com/ziglang/zig/issues/24024
+ const framework_path = framework: {
+ const down1 = std.fs.path.dirname(libc.sys_include_dir.?).?;
+ const down2 = std.fs.path.dirname(down1).?;
+ break :framework try std.fs.path.join(b.allocator, &.{
+ down2,
+ "System",
+ "Library",
+ "Frameworks",
+ });
+ };
+
+ const library_path = library: {
+ const down1 = std.fs.path.dirname(libc.sys_include_dir.?).?;
+ break :library try std.fs.path.join(b.allocator, &.{
+ down1,
+ "lib",
+ });
+ };
+
+ gop.value_ptr.* = .{
+ .libc = path,
+ .framework = framework_path,
+ .system_include = libc.sys_include_dir.?,
+ .library = library_path,
+ };
}
- // The active SDK we want to use
- const path = gop.value_ptr.* orelse return switch (target.os.tag) {
+ const value = gop.value_ptr.* orelse return switch (target.os.tag) {
// Return a more descriptive error. Before we just returned the
// generic error but this was confusing a lot of community members.
// It costs us nothing in the build script to return something better.
@@ -50,7 +100,12 @@ pub fn addPaths(b: *std.Build, m: *std.Build.Module) !void {
.watchos => error.XcodeWatchOSSDKNotFound,
else => error.XcodeAppleSDKNotFound,
};
- m.addSystemFrameworkPath(.{ .cwd_relative = b.pathJoin(&.{ path, "/System/Library/Frameworks" }) });
- m.addSystemIncludePath(.{ .cwd_relative = b.pathJoin(&.{ path, "/usr/include" }) });
- m.addLibraryPath(.{ .cwd_relative = b.pathJoin(&.{ path, "/usr/lib" }) });
+
+ step.setLibCFile(value.libc);
+
+ // This is only necessary until this bug is fixed:
+ // https://github.com/ziglang/zig/issues/24024
+ step.root_module.addSystemFrameworkPath(.{ .cwd_relative = value.framework });
+ step.root_module.addSystemIncludePath(.{ .cwd_relative = value.system_include });
+ step.root_module.addLibraryPath(.{ .cwd_relative = value.library });
}
diff --git a/pkg/breakpad/build.zig b/pkg/breakpad/build.zig
index e2fdec7ad..42247b12c 100644
--- a/pkg/breakpad/build.zig
+++ b/pkg/breakpad/build.zig
@@ -13,7 +13,7 @@ pub fn build(b: *std.Build) !void {
lib.addIncludePath(b.path("vendor"));
if (target.result.os.tag.isDarwin()) {
const apple_sdk = @import("apple_sdk");
- try apple_sdk.addPaths(b, lib.root_module);
+ try apple_sdk.addPaths(b, lib);
}
var flags = std.ArrayList([]const u8).init(b.allocator);
diff --git a/pkg/cimgui/build.zig b/pkg/cimgui/build.zig
index c76b53966..3ca735383 100644
--- a/pkg/cimgui/build.zig
+++ b/pkg/cimgui/build.zig
@@ -84,8 +84,7 @@ pub fn build(b: *std.Build) !void {
if (target.result.os.tag.isDarwin()) {
if (!target.query.isNative()) {
- try @import("apple_sdk").addPaths(b, lib.root_module);
- try @import("apple_sdk").addPaths(b, module);
+ try @import("apple_sdk").addPaths(b, lib);
}
lib.addCSourceFile(.{
.file = imgui.path("backends/imgui_impl_metal.mm"),
diff --git a/pkg/fontconfig/build.zig b/pkg/fontconfig/build.zig
index 77e8df549..9e4173da8 100644
--- a/pkg/fontconfig/build.zig
+++ b/pkg/fontconfig/build.zig
@@ -164,11 +164,23 @@ fn buildLib(b: *std.Build, module: *std.Build.Module, options: anytype) !*std.Bu
"-DHAVE_SYS_STATVFS_H",
"-DFC_CACHEDIR=\"/var/cache/fontconfig\"",
- "-DFC_TEMPLATEDIR=\"/usr/share/fontconfig/conf.avail\"",
- "-DFONTCONFIG_PATH=\"/etc/fonts\"",
- "-DCONFIGDIR=\"/usr/local/fontconfig/conf.d\"",
"-DFC_DEFAULT_FONTS=\"<dir>/usr/share/fonts</dir><dir>/usr/local/share/fonts</dir>\"",
});
+
+ if (target.result.os.tag == .freebsd) {
+ try flags.appendSlice(&.{
+ "-DFC_TEMPLATEDIR=\"/usr/local/etc/fonts/conf.avail\"",
+ "-DFONTCONFIG_PATH=\"/usr/local/etc/fonts\"",
+ "-DCONFIGDIR=\"/usr/local/etc/fonts/conf.d\"",
+ });
+ } else {
+ try flags.appendSlice(&.{
+ "-DFC_TEMPLATEDIR=\"/usr/share/fontconfig/conf.avail\"",
+ "-DFONTCONFIG_PATH=\"/etc/fonts\"",
+ "-DCONFIGDIR=\"/usr/local/fontconfig/conf.d\"",
+ });
+ }
+
if (target.result.os.tag == .linux) {
try flags.appendSlice(&.{
"-DHAVE_SYS_STATFS_H",
diff --git a/pkg/fontconfig/pattern.zig b/pkg/fontconfig/pattern.zig
index e0ec27a69..3a623e223 100644
--- a/pkg/fontconfig/pattern.zig
+++ b/pkg/fontconfig/pattern.zig
@@ -44,7 +44,7 @@ pub const Pattern = opaque {
&val,
))).toError();
- return Value.init(&val);
+ return .init(&val);
}
pub fn delete(self: *Pattern, prop: Property) bool {
@@ -138,7 +138,7 @@ pub const Pattern = opaque {
return Entry{
.result = @enumFromInt(result),
.binding = @enumFromInt(binding),
- .value = Value.init(&value),
+ .value = .init(&value),
};
}
};
diff --git a/pkg/freetype/build.zig b/pkg/freetype/build.zig
index bfe27e5aa..e9f72210a 100644
--- a/pkg/freetype/build.zig
+++ b/pkg/freetype/build.zig
@@ -69,7 +69,7 @@ fn buildLib(b: *std.Build, module: *std.Build.Module, options: anytype) !*std.Bu
lib.linkLibC();
if (target.result.os.tag.isDarwin()) {
const apple_sdk = @import("apple_sdk");
- try apple_sdk.addPaths(b, lib.root_module);
+ try apple_sdk.addPaths(b, lib);
}
var flags = std.ArrayList([]const u8).init(b.allocator);
diff --git a/pkg/glfw/LICENSE b/pkg/glfw/LICENSE
index eeeb852fe..8c422bd23 100644
--- a/pkg/glfw/LICENSE
+++ b/pkg/glfw/LICENSE
@@ -1,5 +1,5 @@
Copyright (c) 2021 Hexops Contributors (given via the Git commit history).
-Copyright (c) 2025 Mitchell Hashimoto
+Copyright (c) 2025 Mitchell Hashimoto, Ghostty contributors
Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
diff --git a/pkg/glfw/Monitor.zig b/pkg/glfw/Monitor.zig
index 4accb23cd..3b194965a 100644
--- a/pkg/glfw/Monitor.zig
+++ b/pkg/glfw/Monitor.zig
@@ -281,7 +281,7 @@ pub inline fn setGamma(self: Monitor, gamma: f32) void {
/// see also: monitor_gamma
pub inline fn getGammaRamp(self: Monitor) ?GammaRamp {
internal_debug.assertInitialized();
- if (c.glfwGetGammaRamp(self.handle)) |ramp| return GammaRamp.fromC(ramp.*);
+ if (c.glfwGetGammaRamp(self.handle)) |ramp| return .fromC(ramp.*);
return null;
}
diff --git a/pkg/glfw/build.zig b/pkg/glfw/build.zig
index cc61f18b2..142a558da 100644
--- a/pkg/glfw/build.zig
+++ b/pkg/glfw/build.zig
@@ -24,7 +24,7 @@ pub fn build(b: *std.Build) !void {
.optimize = optimize,
});
if (target.result.os.tag.isDarwin()) {
- try apple_sdk.addPaths(b, exe.root_module);
+ try apple_sdk.addPaths(b, exe);
}
const tests_run = b.addRunArtifact(exe);
@@ -122,8 +122,7 @@ fn buildLib(
},
.macos => {
- try apple_sdk.addPaths(b, lib.root_module);
- try apple_sdk.addPaths(b, module);
+ try apple_sdk.addPaths(b, lib);
// Transitive dependencies, explicit linkage of these works around
// ziglang/zig#17130
diff --git a/pkg/glfw/opengl.zig b/pkg/glfw/opengl.zig
index 04bc3a65c..8fe2efbed 100644
--- a/pkg/glfw/opengl.zig
+++ b/pkg/glfw/opengl.zig
@@ -47,7 +47,7 @@ pub inline fn makeContextCurrent(window: ?Window) void {
/// see also: context_current, glfwMakeContextCurrent
pub inline fn getCurrentContext() ?Window {
internal_debug.assertInitialized();
- if (c.glfwGetCurrentContext()) |handle| return Window.from(handle);
+ if (c.glfwGetCurrentContext()) |handle| return .from(handle);
return null;
}
diff --git a/pkg/glslang/build.zig b/pkg/glslang/build.zig
index 629490aa4..747216a39 100644
--- a/pkg/glslang/build.zig
+++ b/pkg/glslang/build.zig
@@ -16,10 +16,6 @@ pub fn build(b: *std.Build) !void {
module.addIncludePath(upstream.path(""));
module.addIncludePath(b.path("override"));
- if (target.result.os.tag.isDarwin()) {
- const apple_sdk = @import("apple_sdk");
- try apple_sdk.addPaths(b, module);
- }
if (target.query.isNative()) {
const test_exe = b.addTest(.{
@@ -55,7 +51,7 @@ fn buildGlslang(
lib.addIncludePath(b.path("override"));
if (target.result.os.tag.isDarwin()) {
const apple_sdk = @import("apple_sdk");
- try apple_sdk.addPaths(b, lib.root_module);
+ try apple_sdk.addPaths(b, lib);
}
var flags = std.ArrayList([]const u8).init(b.allocator);
diff --git a/pkg/gtk4-layer-shell/src/main.zig b/pkg/gtk4-layer-shell/src/main.zig
index 88d99772b..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");
});
@@ -27,6 +29,18 @@ pub fn isSupported() bool {
return c.gtk_layer_is_supported() != 0;
}
+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));
}
@@ -46,3 +60,7 @@ pub fn setMargin(window: *gtk.Window, edge: ShellEdge, margin_size: c_int) void
pub fn setKeyboardMode(window: *gtk.Window, mode: KeyboardMode) void {
c.gtk_layer_set_keyboard_mode(@ptrCast(window), @intFromEnum(mode));
}
+
+pub fn setNamespace(window: *gtk.Window, name: [:0]const u8) void {
+ c.gtk_layer_set_namespace(@ptrCast(window), name.ptr);
+}
diff --git a/pkg/harfbuzz/build.zig b/pkg/harfbuzz/build.zig
index d0dd6d01c..3bdc30a32 100644
--- a/pkg/harfbuzz/build.zig
+++ b/pkg/harfbuzz/build.zig
@@ -93,8 +93,7 @@ fn buildLib(b: *std.Build, module: *std.Build.Module, options: anytype) !*std.Bu
lib.linkLibCpp();
if (target.result.os.tag.isDarwin()) {
- try apple_sdk.addPaths(b, lib.root_module);
- try apple_sdk.addPaths(b, module);
+ try apple_sdk.addPaths(b, lib);
}
const dynamic_link_opts = options.dynamic_link_opts;
diff --git a/pkg/highway/build.zig b/pkg/highway/build.zig
index c72ca355f..5036316da 100644
--- a/pkg/highway/build.zig
+++ b/pkg/highway/build.zig
@@ -23,8 +23,7 @@ pub fn build(b: *std.Build) !void {
if (target.result.os.tag.isDarwin()) {
const apple_sdk = @import("apple_sdk");
- try apple_sdk.addPaths(b, lib.root_module);
- try apple_sdk.addPaths(b, module);
+ try apple_sdk.addPaths(b, lib);
}
var flags = std.ArrayList([]const u8).init(b.allocator);
diff --git a/pkg/libintl/build.zig b/pkg/libintl/build.zig
index 53eb67f16..1baed195a 100644
--- a/pkg/libintl/build.zig
+++ b/pkg/libintl/build.zig
@@ -40,7 +40,7 @@ pub fn build(b: *std.Build) !void {
if (target.result.os.tag.isDarwin()) {
const apple_sdk = @import("apple_sdk");
- try apple_sdk.addPaths(b, lib.root_module);
+ try apple_sdk.addPaths(b, lib);
}
if (b.lazyDependency("gettext", .{})) |upstream| {
diff --git a/pkg/libpng/build.zig b/pkg/libpng/build.zig
index d012f2712..8729398f8 100644
--- a/pkg/libpng/build.zig
+++ b/pkg/libpng/build.zig
@@ -15,7 +15,7 @@ pub fn build(b: *std.Build) !void {
}
if (target.result.os.tag.isDarwin()) {
const apple_sdk = @import("apple_sdk");
- try apple_sdk.addPaths(b, lib.root_module);
+ try apple_sdk.addPaths(b, lib);
}
// For dynamic linking, we prefer dynamic linking and to search by
diff --git a/pkg/macos/animation.zig b/pkg/macos/animation.zig
index 5c3c8fd30..247f97605 100644
--- a/pkg/macos/animation.zig
+++ b/pkg/macos/animation.zig
@@ -2,6 +2,8 @@ pub const c = @import("animation/c.zig").c;
/// https://developer.apple.com/documentation/quartzcore/calayer/contents_gravity_values?language=objc
pub extern "c" const kCAGravityTopLeft: *anyopaque;
+pub extern "c" const kCAGravityBottomLeft: *anyopaque;
+pub extern "c" const kCAGravityCenter: *anyopaque;
test {
@import("std").testing.refAllDecls(@This());
diff --git a/pkg/macos/build.zig b/pkg/macos/build.zig
index 911664a2f..3e0a97d1a 100644
--- a/pkg/macos/build.zig
+++ b/pkg/macos/build.zig
@@ -33,6 +33,7 @@ pub fn build(b: *std.Build) !void {
lib.linkFramework("CoreText");
lib.linkFramework("CoreVideo");
lib.linkFramework("QuartzCore");
+ lib.linkFramework("IOSurface");
if (target.result.os.tag == .macos) {
lib.linkFramework("Carbon");
module.linkFramework("Carbon", .{});
@@ -44,9 +45,9 @@ pub fn build(b: *std.Build) !void {
module.linkFramework("CoreText", .{});
module.linkFramework("CoreVideo", .{});
module.linkFramework("QuartzCore", .{});
+ module.linkFramework("IOSurface", .{});
- try apple_sdk.addPaths(b, lib.root_module);
- try apple_sdk.addPaths(b, module);
+ try apple_sdk.addPaths(b, lib);
}
b.installArtifact(lib);
@@ -58,7 +59,7 @@ pub fn build(b: *std.Build) !void {
.optimize = optimize,
});
if (target.result.os.tag.isDarwin()) {
- try apple_sdk.addPaths(b, test_exe.root_module);
+ try apple_sdk.addPaths(b, test_exe);
}
test_exe.linkLibrary(lib);
diff --git a/pkg/macos/dispatch.zig b/pkg/macos/dispatch.zig
index 2bc7e8396..3add9c0e9 100644
--- a/pkg/macos/dispatch.zig
+++ b/pkg/macos/dispatch.zig
@@ -3,6 +3,16 @@ pub const data = @import("dispatch/data.zig");
pub const queue = @import("dispatch/queue.zig");
pub const Data = data.Data;
+pub extern "c" fn dispatch_sync(
+ queue: *anyopaque,
+ block: *anyopaque,
+) void;
+
+pub extern "c" fn dispatch_async(
+ queue: *anyopaque,
+ block: *anyopaque,
+) void;
+
test {
@import("std").testing.refAllDecls(@This());
}
diff --git a/pkg/macos/foundation.zig b/pkg/macos/foundation.zig
index 85562faf0..d4f634091 100644
--- a/pkg/macos/foundation.zig
+++ b/pkg/macos/foundation.zig
@@ -30,6 +30,7 @@ pub const stringGetSurrogatePairForLongCharacter = string.stringGetSurrogatePair
pub const URL = url.URL;
pub const URLPathStyle = url.URLPathStyle;
pub const CFRelease = typepkg.CFRelease;
+pub const CFRetain = typepkg.CFRetain;
test {
@import("std").testing.refAllDecls(@This());
diff --git a/pkg/macos/foundation/type.zig b/pkg/macos/foundation/type.zig
index e3ee150f2..45bd09054 100644
--- a/pkg/macos/foundation/type.zig
+++ b/pkg/macos/foundation/type.zig
@@ -1 +1,2 @@
pub extern "c" fn CFRelease(*anyopaque) void;
+pub extern "c" fn CFRetain(*anyopaque) void;
diff --git a/pkg/macos/iosurface.zig b/pkg/macos/iosurface.zig
new file mode 100644
index 000000000..9d2e750cf
--- /dev/null
+++ b/pkg/macos/iosurface.zig
@@ -0,0 +1,8 @@
+const iosurface = @import("iosurface/iosurface.zig");
+
+pub const c = @import("iosurface/c.zig").c;
+pub const IOSurface = iosurface.IOSurface;
+
+test {
+ @import("std").testing.refAllDecls(@This());
+}
diff --git a/pkg/macos/iosurface/c.zig b/pkg/macos/iosurface/c.zig
new file mode 100644
index 000000000..1a7d1627e
--- /dev/null
+++ b/pkg/macos/iosurface/c.zig
@@ -0,0 +1 @@
+pub const c = @import("../main.zig").c;
diff --git a/pkg/macos/iosurface/iosurface.zig b/pkg/macos/iosurface/iosurface.zig
new file mode 100644
index 000000000..37f8712ba
--- /dev/null
+++ b/pkg/macos/iosurface/iosurface.zig
@@ -0,0 +1,136 @@
+const std = @import("std");
+const assert = std.debug.assert;
+const Allocator = std.mem.Allocator;
+const c = @import("c.zig").c;
+const foundation = @import("../foundation.zig");
+const graphics = @import("../graphics.zig");
+const video = @import("../video.zig");
+
+pub const IOSurface = opaque {
+ pub const Error = error{
+ InvalidOperation,
+ };
+
+ pub const Properties = struct {
+ width: c_int,
+ height: c_int,
+ pixel_format: video.PixelFormat,
+ bytes_per_element: c_int,
+ colorspace: ?*graphics.ColorSpace,
+ };
+
+ pub fn init(properties: Properties) Allocator.Error!*IOSurface {
+ var w = try foundation.Number.create(.int, &properties.width);
+ defer w.release();
+ var h = try foundation.Number.create(.int, &properties.height);
+ defer h.release();
+ var pf = try foundation.Number.create(.int, &@as(c_int, @intFromEnum(properties.pixel_format)));
+ defer pf.release();
+ var bpe = try foundation.Number.create(.int, &properties.bytes_per_element);
+ defer bpe.release();
+
+ var properties_dict = try foundation.Dictionary.create(
+ &[_]?*const anyopaque{
+ c.kIOSurfaceWidth,
+ c.kIOSurfaceHeight,
+ c.kIOSurfacePixelFormat,
+ c.kIOSurfaceBytesPerElement,
+ },
+ &[_]?*const anyopaque{ w, h, pf, bpe },
+ );
+ defer properties_dict.release();
+
+ var surface = @as(?*IOSurface, @ptrFromInt(@intFromPtr(
+ c.IOSurfaceCreate(@ptrCast(properties_dict)),
+ ))) orelse return error.OutOfMemory;
+
+ if (properties.colorspace) |space| {
+ surface.setColorSpace(space);
+ }
+
+ return surface;
+ }
+
+ pub fn deinit(self: *IOSurface) void {
+ // We mark it purgeable so that it is immediately unloaded, so that we
+ // don't have to wait for CoreFoundation garbage collection to trigger.
+ _ = c.IOSurfaceSetPurgeable(
+ @ptrCast(self),
+ c.kIOSurfacePurgeableEmpty,
+ null,
+ );
+ foundation.CFRelease(self);
+ }
+
+ pub fn retain(self: *IOSurface) void {
+ foundation.CFRetain(self);
+ }
+
+ pub fn release(self: *IOSurface) void {
+ foundation.CFRelease(self);
+ }
+
+ pub fn setColorSpace(self: *IOSurface, colorspace: *graphics.ColorSpace) void {
+ const serialized_colorspace = graphics.c.CGColorSpaceCopyPropertyList(
+ @ptrCast(colorspace),
+ ).?;
+ defer foundation.CFRelease(@constCast(serialized_colorspace));
+
+ c.IOSurfaceSetValue(
+ @ptrCast(self),
+ c.kIOSurfaceColorSpace,
+ @ptrCast(serialized_colorspace),
+ );
+ }
+
+ pub inline fn lock(self: *IOSurface) void {
+ c.IOSurfaceLock(
+ @ptrCast(self),
+ 0,
+ null,
+ );
+ }
+ pub inline fn unlock(self: *IOSurface) void {
+ c.IOSurfaceUnlock(
+ @ptrCast(self),
+ 0,
+ null,
+ );
+ }
+
+ pub inline fn getAllocSize(self: *IOSurface) usize {
+ return c.IOSurfaceGetAllocSize(@ptrCast(self));
+ }
+
+ pub inline fn getWidth(self: *IOSurface) usize {
+ return c.IOSurfaceGetWidth(@ptrCast(self));
+ }
+
+ pub inline fn getHeight(self: *IOSurface) usize {
+ return c.IOSurfaceGetHeight(@ptrCast(self));
+ }
+
+ pub inline fn getBytesPerElement(self: *IOSurface) usize {
+ return c.IOSurfaceGetBytesPerElement(@ptrCast(self));
+ }
+
+ pub inline fn getBytesPerRow(self: *IOSurface) usize {
+ return c.IOSurfaceGetBytesPerRow(@ptrCast(self));
+ }
+
+ pub inline fn getBaseAddress(self: *IOSurface) ?[*]u8 {
+ return @ptrCast(c.IOSurfaceGetBaseAddress(@ptrCast(self)));
+ }
+
+ pub inline fn getElementWidth(self: *IOSurface) usize {
+ return c.IOSurfaceGetElementWidth(@ptrCast(self));
+ }
+
+ pub inline fn getElementHeight(self: *IOSurface) usize {
+ return c.IOSurfaceGetElementHeight(@ptrCast(self));
+ }
+
+ pub inline fn getPixelFormat(self: *IOSurface) video.PixelFormat {
+ return @enumFromInt(c.IOSurfaceGetPixelFormat(@ptrCast(self)));
+ }
+};
diff --git a/pkg/macos/main.zig b/pkg/macos/main.zig
index d094b987e..42253ba48 100644
--- a/pkg/macos/main.zig
+++ b/pkg/macos/main.zig
@@ -8,6 +8,7 @@ pub const graphics = @import("graphics.zig");
pub const os = @import("os.zig");
pub const text = @import("text.zig");
pub const video = @import("video.zig");
+pub const iosurface = @import("iosurface.zig");
// All of our C imports consolidated into one place. We used to
// import them one by one in each package but Zig 0.14 has some
@@ -17,7 +18,9 @@ pub const c = @cImport({
@cInclude("CoreGraphics/CoreGraphics.h");
@cInclude("CoreText/CoreText.h");
@cInclude("CoreVideo/CoreVideo.h");
+ @cInclude("CoreVideo/CVPixelBuffer.h");
@cInclude("QuartzCore/CALayer.h");
+ @cInclude("IOSurface/IOSurfaceRef.h");
@cInclude("dispatch/dispatch.h");
@cInclude("os/log.h");
diff --git a/pkg/macos/video.zig b/pkg/macos/video.zig
index 0f5cbc4d6..d0b1125ab 100644
--- a/pkg/macos/video.zig
+++ b/pkg/macos/video.zig
@@ -1,7 +1,9 @@
const display_link = @import("video/display_link.zig");
+const pixel_format = @import("video/pixel_format.zig");
pub const c = @import("video/c.zig").c;
pub const DisplayLink = display_link.DisplayLink;
+pub const PixelFormat = pixel_format.PixelFormat;
test {
@import("std").testing.refAllDecls(@This());
diff --git a/pkg/macos/video/pixel_format.zig b/pkg/macos/video/pixel_format.zig
new file mode 100644
index 000000000..78091daa3
--- /dev/null
+++ b/pkg/macos/video/pixel_format.zig
@@ -0,0 +1,171 @@
+const c = @import("c.zig").c;
+
+pub const PixelFormat = enum(c_int) {
+ /// 1 bit indexed
+ @"1Monochrome" = c.kCVPixelFormatType_1Monochrome,
+ /// 2 bit indexed
+ @"2Indexed" = c.kCVPixelFormatType_2Indexed,
+ /// 4 bit indexed
+ @"4Indexed" = c.kCVPixelFormatType_4Indexed,
+ /// 8 bit indexed
+ @"8Indexed" = c.kCVPixelFormatType_8Indexed,
+ /// 1 bit indexed gray, white is zero
+ @"1IndexedGray_WhiteIsZero" = c.kCVPixelFormatType_1IndexedGray_WhiteIsZero,
+ /// 2 bit indexed gray, white is zero
+ @"2IndexedGray_WhiteIsZero" = c.kCVPixelFormatType_2IndexedGray_WhiteIsZero,
+ /// 4 bit indexed gray, white is zero
+ @"4IndexedGray_WhiteIsZero" = c.kCVPixelFormatType_4IndexedGray_WhiteIsZero,
+ /// 8 bit indexed gray, white is zero
+ @"8IndexedGray_WhiteIsZero" = c.kCVPixelFormatType_8IndexedGray_WhiteIsZero,
+ /// 16 bit BE RGB 555
+ @"16BE555" = c.kCVPixelFormatType_16BE555,
+ /// 16 bit LE RGB 555
+ @"16LE555" = c.kCVPixelFormatType_16LE555,
+ /// 16 bit LE RGB 5551
+ @"16LE5551" = c.kCVPixelFormatType_16LE5551,
+ /// 16 bit BE RGB 565
+ @"16BE565" = c.kCVPixelFormatType_16BE565,
+ /// 16 bit LE RGB 565
+ @"16LE565" = c.kCVPixelFormatType_16LE565,
+ /// 24 bit RGB
+ @"24RGB" = c.kCVPixelFormatType_24RGB,
+ /// 24 bit BGR
+ @"24BGR" = c.kCVPixelFormatType_24BGR,
+ /// 32 bit ARGB
+ @"32ARGB" = c.kCVPixelFormatType_32ARGB,
+ /// 32 bit BGRA
+ @"32BGRA" = c.kCVPixelFormatType_32BGRA,
+ /// 32 bit ABGR
+ @"32ABGR" = c.kCVPixelFormatType_32ABGR,
+ /// 32 bit RGBA
+ @"32RGBA" = c.kCVPixelFormatType_32RGBA,
+ /// 64 bit ARGB, 16-bit big-endian samples
+ @"64ARGB" = c.kCVPixelFormatType_64ARGB,
+ /// 64 bit RGBA, 16-bit little-endian full-range (0-65535) samples
+ @"64RGBALE" = c.kCVPixelFormatType_64RGBALE,
+ /// 48 bit RGB, 16-bit big-endian samples
+ @"48RGB" = c.kCVPixelFormatType_48RGB,
+ /// 32 bit AlphaGray, 16-bit big-endian samples, black is zero
+ @"32AlphaGray" = c.kCVPixelFormatType_32AlphaGray,
+ /// 16 bit Grayscale, 16-bit big-endian samples, black is zero
+ @"16Gray" = c.kCVPixelFormatType_16Gray,
+ /// 30 bit RGB, 10-bit big-endian samples, 2 unused padding bits (at least significant end).
+ @"30RGB" = c.kCVPixelFormatType_30RGB,
+ /// 30 bit RGB, 10-bit big-endian samples, 2 unused padding bits (at most significant end), video-range (64-940).
+ @"30RGB_r210" = c.kCVPixelFormatType_30RGB_r210,
+ /// Component Y'CbCr 8-bit 4:2:2, ordered Cb Y'0 Cr Y'1
+ @"422YpCbCr8" = c.kCVPixelFormatType_422YpCbCr8,
+ /// Component Y'CbCrA 8-bit 4:4:4:4, ordered Cb Y' Cr A
+ @"4444YpCbCrA8" = c.kCVPixelFormatType_4444YpCbCrA8,
+ /// Component Y'CbCrA 8-bit 4:4:4:4, rendering format. full range alpha, zero biased YUV, ordered A Y' Cb Cr
+ @"4444YpCbCrA8R" = c.kCVPixelFormatType_4444YpCbCrA8R,
+ /// Component Y'CbCrA 8-bit 4:4:4:4, ordered A Y' Cb Cr, full range alpha, video range Y'CbCr.
+ @"4444AYpCbCr8" = c.kCVPixelFormatType_4444AYpCbCr8,
+ /// Component Y'CbCrA 16-bit 4:4:4:4, ordered A Y' Cb Cr, full range alpha, video range Y'CbCr, 16-bit little-endian samples.
+ @"4444AYpCbCr16" = c.kCVPixelFormatType_4444AYpCbCr16,
+ /// Component AY'CbCr single precision floating-point 4:4:4:4
+ @"4444AYpCbCrFloat" = c.kCVPixelFormatType_4444AYpCbCrFloat,
+ /// Component Y'CbCr 8-bit 4:4:4, ordered Cr Y' Cb, video range Y'CbCr
+ @"444YpCbCr8" = c.kCVPixelFormatType_444YpCbCr8,
+ /// Component Y'CbCr 10,12,14,16-bit 4:2:2
+ @"422YpCbCr16" = c.kCVPixelFormatType_422YpCbCr16,
+ /// Component Y'CbCr 10-bit 4:2:2
+ @"422YpCbCr10" = c.kCVPixelFormatType_422YpCbCr10,
+ /// Component Y'CbCr 10-bit 4:4:4
+ @"444YpCbCr10" = c.kCVPixelFormatType_444YpCbCr10,
+ /// Planar Component Y'CbCr 8-bit 4:2:0. baseAddr points to a big-endian CVPlanarPixelBufferInfo_YCbCrPlanar struct
+ @"420YpCbCr8Planar" = c.kCVPixelFormatType_420YpCbCr8Planar,
+ /// Planar Component Y'CbCr 8-bit 4:2:0, full range. baseAddr points to a big-endian CVPlanarPixelBufferInfo_YCbCrPlanar struct
+ @"420YpCbCr8PlanarFullRange" = c.kCVPixelFormatType_420YpCbCr8PlanarFullRange,
+ /// First plane: Video-range Component Y'CbCr 8-bit 4:2:2, ordered Cb Y'0 Cr Y'1; second plane: alpha 8-bit 0-255
+ @"422YpCbCr_4A_8BiPlanar" = c.kCVPixelFormatType_422YpCbCr_4A_8BiPlanar,
+ /// Bi-Planar Component Y'CbCr 8-bit 4:2:0, video-range (luma=[16,235] chroma=[16,240]). baseAddr points to a big-endian CVPlanarPixelBufferInfo_YCbCrBiPlanar struct
+ @"420YpCbCr8BiPlanarVideoRange" = c.kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange,
+ /// Bi-Planar Component Y'CbCr 8-bit 4:2:0, full-range (luma=[0,255] chroma=[1,255]). baseAddr points to a big-endian CVPlanarPixelBufferInfo_YCbCrBiPlanar struct
+ @"420YpCbCr8BiPlanarFullRange" = c.kCVPixelFormatType_420YpCbCr8BiPlanarFullRange,
+ /// Bi-Planar Component Y'CbCr 8-bit 4:2:2, video-range (luma=[16,235] chroma=[16,240]). baseAddr points to a big-endian CVPlanarPixelBufferInfo_YCbCrBiPlanar struct
+ @"422YpCbCr8BiPlanarVideoRange" = c.kCVPixelFormatType_422YpCbCr8BiPlanarVideoRange,
+ /// Bi-Planar Component Y'CbCr 8-bit 4:2:2, full-range (luma=[0,255] chroma=[1,255]). baseAddr points to a big-endian CVPlanarPixelBufferInfo_YCbCrBiPlanar struct
+ @"422YpCbCr8BiPlanarFullRange" = c.kCVPixelFormatType_422YpCbCr8BiPlanarFullRange,
+ /// Bi-Planar Component Y'CbCr 8-bit 4:4:4, video-range (luma=[16,235] chroma=[16,240]). baseAddr points to a big-endian CVPlanarPixelBufferInfo_YCbCrBiPlanar struct
+ @"444YpCbCr8BiPlanarVideoRange" = c.kCVPixelFormatType_444YpCbCr8BiPlanarVideoRange,
+ /// Bi-Planar Component Y'CbCr 8-bit 4:4:4, full-range (luma=[0,255] chroma=[1,255]). baseAddr points to a big-endian CVPlanarPixelBufferInfo_YCbCrBiPlanar struct
+ @"444YpCbCr8BiPlanarFullRange" = c.kCVPixelFormatType_444YpCbCr8BiPlanarFullRange,
+ /// Component Y'CbCr 8-bit 4:2:2, ordered Y'0 Cb Y'1 Cr
+ @"422YpCbCr8_yuvs" = c.kCVPixelFormatType_422YpCbCr8_yuvs,
+ /// Component Y'CbCr 8-bit 4:2:2, full range, ordered Y'0 Cb Y'1 Cr
+ @"422YpCbCr8FullRange" = c.kCVPixelFormatType_422YpCbCr8FullRange,
+ /// 8 bit one component, black is zero
+ OneComponent8 = c.kCVPixelFormatType_OneComponent8,
+ /// 8 bit two component, black is zero
+ TwoComponent8 = c.kCVPixelFormatType_TwoComponent8,
+ /// little-endian RGB101010, 2 MSB are ignored, wide-gamut (384-895)
+ @"30RGBLEPackedWideGamut" = c.kCVPixelFormatType_30RGBLEPackedWideGamut,
+ /// little-endian ARGB2101010 full-range ARGB
+ ARGB2101010LEPacked = c.kCVPixelFormatType_ARGB2101010LEPacked,
+ /// little-endian ARGB10101010, each 10 bits in the MSBs of 16bits, wide-gamut (384-895, including alpha)
+ @"40ARGBLEWideGamut" = c.kCVPixelFormatType_40ARGBLEWideGamut,
+ /// little-endian ARGB10101010, each 10 bits in the MSBs of 16bits, wide-gamut (384-895, including alpha). Alpha premultiplied
+ @"40ARGBLEWideGamutPremultiplied" = c.kCVPixelFormatType_40ARGBLEWideGamutPremultiplied,
+ /// 10 bit little-endian one component, stored as 10 MSBs of 16 bits, black is zero
+ OneComponent10 = c.kCVPixelFormatType_OneComponent10,
+ /// 12 bit little-endian one component, stored as 12 MSBs of 16 bits, black is zero
+ OneComponent12 = c.kCVPixelFormatType_OneComponent12,
+ /// 16 bit little-endian one component, black is zero
+ OneComponent16 = c.kCVPixelFormatType_OneComponent16,
+ /// 16 bit little-endian two component, black is zero
+ TwoComponent16 = c.kCVPixelFormatType_TwoComponent16,
+ /// 16 bit one component IEEE half-precision float, 16-bit little-endian samples
+ OneComponent16Half = c.kCVPixelFormatType_OneComponent16Half,
+ /// 32 bit one component IEEE float, 32-bit little-endian samples
+ OneComponent32Float = c.kCVPixelFormatType_OneComponent32Float,
+ /// 16 bit two component IEEE half-precision float, 16-bit little-endian samples
+ TwoComponent16Half = c.kCVPixelFormatType_TwoComponent16Half,
+ /// 32 bit two component IEEE float, 32-bit little-endian samples
+ TwoComponent32Float = c.kCVPixelFormatType_TwoComponent32Float,
+ /// 64 bit RGBA IEEE half-precision float, 16-bit little-endian samples
+ @"64RGBAHalf" = c.kCVPixelFormatType_64RGBAHalf,
+ /// 128 bit RGBA IEEE float, 32-bit little-endian samples
+ @"128RGBAFloat" = c.kCVPixelFormatType_128RGBAFloat,
+ /// Bayer 14-bit Little-Endian, packed in 16-bits, ordered G R G R... alternating with B G B G...
+ @"14Bayer_GRBG" = c.kCVPixelFormatType_14Bayer_GRBG,
+ /// Bayer 14-bit Little-Endian, packed in 16-bits, ordered R G R G... alternating with G B G B...
+ @"14Bayer_RGGB" = c.kCVPixelFormatType_14Bayer_RGGB,
+ /// Bayer 14-bit Little-Endian, packed in 16-bits, ordered B G B G... alternating with G R G R...
+ @"14Bayer_BGGR" = c.kCVPixelFormatType_14Bayer_BGGR,
+ /// Bayer 14-bit Little-Endian, packed in 16-bits, ordered G B G B... alternating with R G R G...
+ @"14Bayer_GBRG" = c.kCVPixelFormatType_14Bayer_GBRG,
+ /// IEEE754-2008 binary16 (half float), describing the normalized shift when comparing two images. Units are 1/meters: ( pixelShift / (pixelFocalLength * baselineInMeters) )
+ DisparityFloat16 = c.kCVPixelFormatType_DisparityFloat16,
+ /// IEEE754-2008 binary32 float, describing the normalized shift when comparing two images. Units are 1/meters: ( pixelShift / (pixelFocalLength * baselineInMeters) )
+ DisparityFloat32 = c.kCVPixelFormatType_DisparityFloat32,
+ /// IEEE754-2008 binary16 (half float), describing the depth (distance to an object) in meters
+ DepthFloat16 = c.kCVPixelFormatType_DepthFloat16,
+ /// IEEE754-2008 binary32 float, describing the depth (distance to an object) in meters
+ DepthFloat32 = c.kCVPixelFormatType_DepthFloat32,
+ /// 2 plane YCbCr10 4:2:0, each 10 bits in the MSBs of 16bits, video-range (luma=[64,940] chroma=[64,960])
+ @"420YpCbCr10BiPlanarVideoRange" = c.kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange,
+ /// 2 plane YCbCr10 4:2:2, each 10 bits in the MSBs of 16bits, video-range (luma=[64,940] chroma=[64,960])
+ @"422YpCbCr10BiPlanarVideoRange" = c.kCVPixelFormatType_422YpCbCr10BiPlanarVideoRange,
+ /// 2 plane YCbCr10 4:4:4, each 10 bits in the MSBs of 16bits, video-range (luma=[64,940] chroma=[64,960])
+ @"444YpCbCr10BiPlanarVideoRange" = c.kCVPixelFormatType_444YpCbCr10BiPlanarVideoRange,
+ /// 2 plane YCbCr10 4:2:0, each 10 bits in the MSBs of 16bits, full-range (Y range 0-1023)
+ @"420YpCbCr10BiPlanarFullRange" = c.kCVPixelFormatType_420YpCbCr10BiPlanarFullRange,
+ /// 2 plane YCbCr10 4:2:2, each 10 bits in the MSBs of 16bits, full-range (Y range 0-1023)
+ @"422YpCbCr10BiPlanarFullRange" = c.kCVPixelFormatType_422YpCbCr10BiPlanarFullRange,
+ /// 2 plane YCbCr10 4:4:4, each 10 bits in the MSBs of 16bits, full-range (Y range 0-1023)
+ @"444YpCbCr10BiPlanarFullRange" = c.kCVPixelFormatType_444YpCbCr10BiPlanarFullRange,
+ /// first and second planes as per 420YpCbCr8BiPlanarVideoRange (420v), alpha 8 bits in third plane full-range. No CVPlanarPixelBufferInfo struct.
+ @"420YpCbCr8VideoRange_8A_TriPlanar" = c.kCVPixelFormatType_420YpCbCr8VideoRange_8A_TriPlanar,
+ /// Single plane Bayer 16-bit little-endian sensor element ("sensel".*) samples from full-size decoding of ProRes RAW images; Bayer pattern (sensel ordering) and other raw conversion information is described via buffer attachments
+ @"16VersatileBayer" = c.kCVPixelFormatType_16VersatileBayer,
+ /// Single plane 64-bit RGBA (16-bit little-endian samples) from downscaled decoding of ProRes RAW images; components--which may not be co-sited with one another--are sensel values and require raw conversion, information for which is described via buffer attachments
+ @"64RGBA_DownscaledProResRAW" = c.kCVPixelFormatType_64RGBA_DownscaledProResRAW,
+ /// 2 plane YCbCr16 4:2:2, video-range (luma=[4096,60160] chroma=[4096,61440])
+ @"422YpCbCr16BiPlanarVideoRange" = c.kCVPixelFormatType_422YpCbCr16BiPlanarVideoRange,
+ /// 2 plane YCbCr16 4:4:4, video-range (luma=[4096,60160] chroma=[4096,61440])
+ @"444YpCbCr16BiPlanarVideoRange" = c.kCVPixelFormatType_444YpCbCr16BiPlanarVideoRange,
+ /// 3 plane video-range YCbCr16 4:4:4 with 16-bit full-range alpha (luma=[4096,60160] chroma=[4096,61440] alpha=[0,65535]). No CVPlanarPixelBufferInfo struct.
+ @"444YpCbCr16VideoRange_16A_TriPlanar" = c.kCVPixelFormatType_444YpCbCr16VideoRange_16A_TriPlanar,
+ _,
+};
diff --git a/pkg/oniguruma/build.zig b/pkg/oniguruma/build.zig
index 1c93bbf9a..c23d744df 100644
--- a/pkg/oniguruma/build.zig
+++ b/pkg/oniguruma/build.zig
@@ -67,7 +67,7 @@ fn buildLib(b: *std.Build, module: *std.Build.Module, options: anytype) !*std.Bu
if (target.result.os.tag.isDarwin()) {
const apple_sdk = @import("apple_sdk");
- try apple_sdk.addPaths(b, lib.root_module);
+ try apple_sdk.addPaths(b, lib);
}
if (b.lazyDependency("oniguruma", .{})) |upstream| {
diff --git a/pkg/opengl/Buffer.zig b/pkg/opengl/Buffer.zig
index 3e55410b7..609342958 100644
--- a/pkg/opengl/Buffer.zig
+++ b/pkg/opengl/Buffer.zig
@@ -51,7 +51,7 @@ pub const Binding = struct {
data: anytype,
usage: Usage,
) !void {
- const info = dataInfo(&data);
+ const info = dataInfo(data);
glad.context.BufferData.?(
@intFromEnum(b.target),
info.size,
@@ -136,10 +136,6 @@ pub const Binding = struct {
};
}
- pub fn enableAttribArray(_: Binding, idx: c.GLuint) !void {
- glad.context.EnableVertexAttribArray.?(idx);
- }
-
/// Shorthand for vertexAttribPointer that is specialized towards the
/// common use case of specifying an array of homogeneous types that
/// don't need normalization. This also enables the attribute at idx.
@@ -230,6 +226,7 @@ pub const Target = enum(c_uint) {
array = c.GL_ARRAY_BUFFER,
element_array = c.GL_ELEMENT_ARRAY_BUFFER,
uniform = c.GL_UNIFORM_BUFFER,
+ storage = c.GL_SHADER_STORAGE_BUFFER,
_,
};
diff --git a/pkg/opengl/Framebuffer.zig b/pkg/opengl/Framebuffer.zig
index c5d659f98..ea1f0d2ba 100644
--- a/pkg/opengl/Framebuffer.zig
+++ b/pkg/opengl/Framebuffer.zig
@@ -5,6 +5,7 @@ const c = @import("c.zig").c;
const errors = @import("errors.zig");
const glad = @import("glad.zig");
const Texture = @import("Texture.zig");
+const Renderbuffer = @import("Renderbuffer.zig");
id: c.GLuint,
@@ -86,6 +87,29 @@ pub const Binding = struct {
try errors.getError();
}
+ pub fn renderbuffer(
+ self: Binding,
+ attachment: Attachment,
+ buffer: Renderbuffer,
+ ) !void {
+ glad.context.FramebufferRenderbuffer.?(
+ @intFromEnum(self.target),
+ @intFromEnum(attachment),
+ c.GL_RENDERBUFFER,
+ buffer.id,
+ );
+ try errors.getError();
+ }
+
+ pub fn drawBuffers(
+ self: Binding,
+ bufs: []Attachment,
+ ) !void {
+ _ = self;
+ glad.context.DrawBuffers.?(@intCast(bufs.len), bufs.ptr);
+ try errors.getError();
+ }
+
pub fn checkStatus(self: Binding) Status {
return @enumFromInt(glad.context.CheckFramebufferStatus.?(@intFromEnum(self.target)));
}
diff --git a/pkg/opengl/Renderbuffer.zig b/pkg/opengl/Renderbuffer.zig
new file mode 100644
index 000000000..ef21287f7
--- /dev/null
+++ b/pkg/opengl/Renderbuffer.zig
@@ -0,0 +1,56 @@
+const Renderbuffer = @This();
+
+const std = @import("std");
+const c = @import("c.zig").c;
+const errors = @import("errors.zig");
+const glad = @import("glad.zig");
+
+const Texture = @import("Texture.zig");
+
+id: c.GLuint,
+
+/// Create a single buffer.
+pub fn create() !Renderbuffer {
+ var rbo: c.GLuint = undefined;
+ glad.context.GenRenderbuffers.?(1, &rbo);
+ return .{ .id = rbo };
+}
+
+pub fn destroy(v: Renderbuffer) void {
+ glad.context.DeleteRenderbuffers.?(1, &v.id);
+}
+
+pub fn bind(v: Renderbuffer) !Binding {
+ // Keep track of the previous binding so we can restore it in unbind.
+ var current: c.GLint = undefined;
+ glad.context.GetIntegerv.?(c.GL_RENDERBUFFER_BINDING, &current);
+ glad.context.BindRenderbuffer.?(c.GL_RENDERBUFFER, v.id);
+ return .{ .previous = @intCast(current) };
+}
+
+pub const Binding = struct {
+ previous: c.GLuint,
+
+ pub fn unbind(self: Binding) void {
+ glad.context.BindRenderbuffer.?(
+ c.GL_RENDERBUFFER,
+ self.previous,
+ );
+ }
+
+ pub fn storage(
+ self: Binding,
+ format: Texture.InternalFormat,
+ width: c.GLsizei,
+ height: c.GLsizei,
+ ) !void {
+ _ = self;
+ glad.context.RenderbufferStorage.?(
+ c.GL_RENDERBUFFER,
+ @intCast(@intFromEnum(format)),
+ width,
+ height,
+ );
+ try errors.getError();
+ }
+};
diff --git a/pkg/opengl/Texture.zig b/pkg/opengl/Texture.zig
index fa5cf770b..2c8e05eff 100644
--- a/pkg/opengl/Texture.zig
+++ b/pkg/opengl/Texture.zig
@@ -7,15 +7,16 @@ const glad = @import("glad.zig");
id: c.GLuint,
-pub fn active(target: c.GLenum) !void {
- glad.context.ActiveTexture.?(target);
+pub fn active(index: c_uint) errors.Error!void {
+ glad.context.ActiveTexture.?(index + c.GL_TEXTURE0);
try errors.getError();
}
/// Create a single texture.
-pub fn create() !Texture {
+pub fn create() errors.Error!Texture {
var id: c.GLuint = undefined;
glad.context.GenTextures.?(1, &id);
+ try errors.getError();
return .{ .id = id };
}
@@ -30,7 +31,7 @@ pub fn destroy(v: Texture) void {
glad.context.DeleteTextures.?(1, &v.id);
}
-/// Enun for possible texture binding targets.
+/// Enum for possible texture binding targets.
pub const Target = enum(c_uint) {
@"1D" = c.GL_TEXTURE_1D,
@"2D" = c.GL_TEXTURE_2D,
@@ -67,11 +68,14 @@ pub const Parameter = enum(c_uint) {
/// Internal format enum for texture images.
pub const InternalFormat = enum(c_int) {
red = c.GL_RED,
- rgb = c.GL_RGB,
- rgba = c.GL_RGBA,
+ rgb = c.GL_RGB8,
+ rgba = c.GL_RGBA8,
- srgb = c.GL_SRGB,
- srgba = c.GL_SRGB_ALPHA,
+ 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.
_,
@@ -107,7 +111,7 @@ pub const Binding = struct {
glad.context.GenerateMipmap.?(@intFromEnum(b.target));
}
- pub fn parameter(b: Binding, name: Parameter, value: anytype) !void {
+ pub fn parameter(b: Binding, name: Parameter, value: anytype) errors.Error!void {
switch (@TypeOf(value)) {
c.GLint => glad.context.TexParameteri.?(
@intFromEnum(b.target),
@@ -116,6 +120,7 @@ pub const Binding = struct {
),
else => unreachable,
}
+ try errors.getError();
}
pub fn image2D(
@@ -124,22 +129,22 @@ pub const Binding = struct {
internal_format: InternalFormat,
width: c.GLsizei,
height: c.GLsizei,
- border: c.GLint,
format: Format,
typ: DataType,
data: ?*const anyopaque,
- ) !void {
+ ) errors.Error!void {
glad.context.TexImage2D.?(
@intFromEnum(b.target),
level,
@intFromEnum(internal_format),
width,
height,
- border,
+ 0,
@intFromEnum(format),
@intFromEnum(typ),
data,
);
+ try errors.getError();
}
pub fn subImage2D(
@@ -152,7 +157,7 @@ pub const Binding = struct {
format: Format,
typ: DataType,
data: ?*const anyopaque,
- ) !void {
+ ) errors.Error!void {
glad.context.TexSubImage2D.?(
@intFromEnum(b.target),
level,
@@ -164,6 +169,7 @@ pub const Binding = struct {
@intFromEnum(typ),
data,
);
+ try errors.getError();
}
pub fn copySubImage2D(
@@ -175,7 +181,17 @@ pub const Binding = struct {
y: c.GLint,
width: c.GLsizei,
height: c.GLsizei,
- ) !void {
- glad.context.CopyTexSubImage2D.?(@intFromEnum(b.target), level, xoffset, yoffset, x, y, width, height);
+ ) errors.Error!void {
+ glad.context.CopyTexSubImage2D.?(
+ @intFromEnum(b.target),
+ level,
+ xoffset,
+ yoffset,
+ x,
+ y,
+ width,
+ height,
+ );
+ try errors.getError();
}
};
diff --git a/pkg/opengl/VertexArray.zig b/pkg/opengl/VertexArray.zig
index 4a6a37576..44bf31621 100644
--- a/pkg/opengl/VertexArray.zig
+++ b/pkg/opengl/VertexArray.zig
@@ -29,4 +29,88 @@ pub const Binding = struct {
_ = self;
glad.context.BindVertexArray.?(0);
}
+
+ pub fn enableAttribArray(_: Binding, idx: c.GLuint) !void {
+ glad.context.EnableVertexAttribArray.?(idx);
+ try errors.getError();
+ }
+
+ pub fn bindingDivisor(_: Binding, idx: c.GLuint, divisor: c.GLuint) !void {
+ glad.context.VertexBindingDivisor.?(idx, divisor);
+ try errors.getError();
+ }
+
+ pub fn attributeBinding(
+ _: Binding,
+ attrib_idx: c.GLuint,
+ binding_idx: c.GLuint,
+ ) !void {
+ glad.context.VertexAttribBinding.?(attrib_idx, binding_idx);
+ try errors.getError();
+ }
+
+ pub fn attributeFormat(
+ _: Binding,
+ idx: c.GLuint,
+ size: c.GLint,
+ typ: c.GLenum,
+ normalized: bool,
+ offset: c.GLuint,
+ ) !void {
+ glad.context.VertexAttribFormat.?(
+ idx,
+ size,
+ typ,
+ @intCast(@intFromBool(normalized)),
+ offset,
+ );
+ try errors.getError();
+ }
+
+ pub fn attributeIFormat(
+ _: Binding,
+ idx: c.GLuint,
+ size: c.GLint,
+ typ: c.GLenum,
+ offset: c.GLuint,
+ ) !void {
+ glad.context.VertexAttribIFormat.?(
+ idx,
+ size,
+ typ,
+ offset,
+ );
+ try errors.getError();
+ }
+
+ pub fn attributeLFormat(
+ _: Binding,
+ idx: c.GLuint,
+ size: c.GLint,
+ offset: c.GLuint,
+ ) !void {
+ glad.context.VertexAttribLFormat.?(
+ idx,
+ size,
+ c.GL_DOUBLE,
+ offset,
+ );
+ try errors.getError();
+ }
+
+ pub fn bindVertexBuffer(
+ _: Binding,
+ idx: c.GLuint,
+ buffer: c.GLuint,
+ offset: c.GLintptr,
+ stride: c.GLsizei,
+ ) !void {
+ glad.context.BindVertexBuffer.?(
+ idx,
+ buffer,
+ offset,
+ stride,
+ );
+ try errors.getError();
+ }
};
diff --git a/pkg/opengl/draw.zig b/pkg/opengl/draw.zig
index 866511c32..50110f605 100644
--- a/pkg/opengl/draw.zig
+++ b/pkg/opengl/draw.zig
@@ -1,6 +1,7 @@
const c = @import("c.zig").c;
const errors = @import("errors.zig");
const glad = @import("glad.zig");
+const Primitive = @import("primitives.zig").Primitive;
pub fn clearColor(r: f32, g: f32, b: f32, a: f32) void {
glad.context.ClearColor.?(r, g, b, a);
@@ -15,6 +16,21 @@ pub fn drawArrays(mode: c.GLenum, first: c.GLint, count: c.GLsizei) !void {
try errors.getError();
}
+pub fn drawArraysInstanced(
+ mode: Primitive,
+ first: c.GLint,
+ count: c.GLsizei,
+ primcount: c.GLsizei,
+) !void {
+ glad.context.DrawArraysInstanced.?(
+ @intCast(@intFromEnum(mode)),
+ first,
+ count,
+ primcount,
+ );
+ try errors.getError();
+}
+
pub fn drawElements(mode: c.GLenum, count: c.GLsizei, typ: c.GLenum, offset: usize) !void {
const offsetPtr = if (offset == 0) null else @as(*const anyopaque, @ptrFromInt(offset));
glad.context.DrawElements.?(mode, count, typ, offsetPtr);
@@ -25,9 +41,15 @@ pub fn drawElementsInstanced(
mode: c.GLenum,
count: c.GLsizei,
typ: c.GLenum,
- primcount: usize,
+ primcount: c.GLsizei,
) !void {
- glad.context.DrawElementsInstanced.?(mode, count, typ, null, @intCast(primcount));
+ glad.context.DrawElementsInstanced.?(
+ mode,
+ count,
+ typ,
+ null,
+ primcount,
+ );
try errors.getError();
}
@@ -36,6 +58,11 @@ pub fn enable(cap: c.GLenum) !void {
try errors.getError();
}
+pub fn disable(cap: c.GLenum) !void {
+ glad.context.Disable.?(cap);
+ try errors.getError();
+}
+
pub fn frontFace(mode: c.GLenum) !void {
glad.context.FrontFace.?(mode);
try errors.getError();
@@ -57,3 +84,11 @@ pub fn pixelStore(mode: c.GLenum, value: anytype) !void {
}
try errors.getError();
}
+
+pub fn finish() void {
+ glad.context.Finish.?();
+}
+
+pub fn flush() void {
+ glad.context.Flush.?();
+}
diff --git a/pkg/opengl/main.zig b/pkg/opengl/main.zig
index 19cd750d0..7165ad3ab 100644
--- a/pkg/opengl/main.zig
+++ b/pkg/opengl/main.zig
@@ -16,20 +16,29 @@ pub const glad = @import("glad.zig");
pub const ext = @import("extensions.zig");
pub const Buffer = @import("Buffer.zig");
pub const Framebuffer = @import("Framebuffer.zig");
+pub const Renderbuffer = @import("Renderbuffer.zig");
pub const Program = @import("Program.zig");
pub const Shader = @import("Shader.zig");
pub const Texture = @import("Texture.zig");
pub const VertexArray = @import("VertexArray.zig");
+pub const errors = @import("errors.zig");
+
+pub const Primitive = @import("primitives.zig").Primitive;
+
const draw = @import("draw.zig");
pub const blendFunc = draw.blendFunc;
pub const clear = draw.clear;
pub const clearColor = draw.clearColor;
pub const drawArrays = draw.drawArrays;
+pub const drawArraysInstanced = draw.drawArraysInstanced;
pub const drawElements = draw.drawElements;
pub const drawElementsInstanced = draw.drawElementsInstanced;
pub const enable = draw.enable;
+pub const disable = draw.disable;
pub const frontFace = draw.frontFace;
pub const pixelStore = draw.pixelStore;
pub const viewport = draw.viewport;
+pub const flush = draw.flush;
+pub const finish = draw.finish;
diff --git a/pkg/opengl/primitives.zig b/pkg/opengl/primitives.zig
new file mode 100644
index 000000000..e12f51a66
--- /dev/null
+++ b/pkg/opengl/primitives.zig
@@ -0,0 +1,18 @@
+pub const c = @import("c.zig").c;
+
+pub const Primitive = enum(c_int) {
+ point = c.GL_POINTS,
+ line = c.GL_LINES,
+ line_strip = c.GL_LINE_STRIP,
+ triangle = c.GL_TRIANGLES,
+ triangle_strip = c.GL_TRIANGLE_STRIP,
+
+ // Commented out primitive types are excluded for parity with Metal.
+ //
+ // line_loop = c.GL_LINE_LOOP,
+ // line_adjacency = c.GL_LINES_ADJACENCY,
+ // line_strip_adjacency = c.GL_LINE_STRIP_ADJACENCY,
+ // triangle_fan = c.GL_TRIANGLE_FAN,
+ // triangle_adjacency = c.GL_TRIANGLES_ADJACENCY,
+ // triangle_strip_adjacency = c.GL_TRIANGLE_STRIP_ADJACENCY,
+};
diff --git a/pkg/sentry/build.zig b/pkg/sentry/build.zig
index 3c0019710..0e6993ad4 100644
--- a/pkg/sentry/build.zig
+++ b/pkg/sentry/build.zig
@@ -20,8 +20,7 @@ pub fn build(b: *std.Build) !void {
lib.linkLibC();
if (target.result.os.tag.isDarwin()) {
const apple_sdk = @import("apple_sdk");
- try apple_sdk.addPaths(b, lib.root_module);
- try apple_sdk.addPaths(b, module);
+ try apple_sdk.addPaths(b, lib);
}
var flags = std.ArrayList([]const u8).init(b.allocator);
diff --git a/pkg/simdutf/build.zig b/pkg/simdutf/build.zig
index 859653443..30de40fea 100644
--- a/pkg/simdutf/build.zig
+++ b/pkg/simdutf/build.zig
@@ -14,7 +14,7 @@ pub fn build(b: *std.Build) !void {
if (target.result.os.tag.isDarwin()) {
const apple_sdk = @import("apple_sdk");
- try apple_sdk.addPaths(b, lib.root_module);
+ try apple_sdk.addPaths(b, lib);
}
var flags = std.ArrayList([]const u8).init(b.allocator);
diff --git a/pkg/spirv-cross/build.zig b/pkg/spirv-cross/build.zig
index c7d0d2039..ff67e3e72 100644
--- a/pkg/spirv-cross/build.zig
+++ b/pkg/spirv-cross/build.zig
@@ -44,7 +44,7 @@ fn buildSpirvCross(
lib.linkLibCpp();
if (target.result.os.tag.isDarwin()) {
const apple_sdk = @import("apple_sdk");
- try apple_sdk.addPaths(b, lib.root_module);
+ try apple_sdk.addPaths(b, lib);
}
var flags = std.ArrayList([]const u8).init(b.allocator);
diff --git a/pkg/utfcpp/build.zig b/pkg/utfcpp/build.zig
index 6b80fec7b..8e1a3cb20 100644
--- a/pkg/utfcpp/build.zig
+++ b/pkg/utfcpp/build.zig
@@ -13,7 +13,7 @@ pub fn build(b: *std.Build) !void {
if (target.result.os.tag.isDarwin()) {
const apple_sdk = @import("apple_sdk");
- try apple_sdk.addPaths(b, lib.root_module);
+ try apple_sdk.addPaths(b, lib);
}
var flags = std.ArrayList([]const u8).init(b.allocator);
diff --git a/pkg/wuffs/build.zig b/pkg/wuffs/build.zig
index d47771c22..4d144e76a 100644
--- a/pkg/wuffs/build.zig
+++ b/pkg/wuffs/build.zig
@@ -11,11 +11,6 @@ pub fn build(b: *std.Build) !void {
.link_libc = true,
});
- if (target.result.os.tag.isDarwin()) {
- const apple_sdk = @import("apple_sdk");
- try apple_sdk.addPaths(b, module);
- }
-
const unit_tests = b.addTest(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
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/pkg/zlib/build.zig b/pkg/zlib/build.zig
index 28ae62424..28344c989 100644
--- a/pkg/zlib/build.zig
+++ b/pkg/zlib/build.zig
@@ -12,7 +12,7 @@ pub fn build(b: *std.Build) !void {
lib.linkLibC();
if (target.result.os.tag.isDarwin()) {
const apple_sdk = @import("apple_sdk");
- try apple_sdk.addPaths(b, lib.root_module);
+ try apple_sdk.addPaths(b, lib);
}
if (b.lazyDependency("zlib", .{})) |upstream| {
diff --git a/po/ca_ES.UTF-8.po b/po/ca_ES.UTF-8.po
index 712f0d5af..653439fa2 100644
--- a/po/ca_ES.UTF-8.po
+++ b/po/ca_ES.UTF-8.po
@@ -1,5 +1,5 @@
# Catalan translations for com.mitchellh.ghostty package.
-# Copyright (C) 2025 Mitchell Hashimoto
+# Copyright (C) 2025 Mitchell Hashimoto, Ghostty contributors
# This file is distributed under the same license as the com.mitchellh.ghostty package.
# Francesc Arpi <francesc.arpi@gmail.com>, 2025.
#
diff --git a/po/com.mitchellh.ghostty.pot b/po/com.mitchellh.ghostty.pot
index d6a99d01d..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
+# Copyright (C) YEAR "Mitchell Hashimoto, Ghostty contributors"
# This file is distributed under the same license as the com.mitchellh.ghostty package.
# FIRST AUTHOR <EMAIL@ADDRESS>, 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 <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\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/de_DE.UTF-8.po b/po/de_DE.UTF-8.po
index 44f3bae39..2d3b96d81 100644
--- a/po/de_DE.UTF-8.po
+++ b/po/de_DE.UTF-8.po
@@ -1,6 +1,6 @@
# German translations for com.mitchellh.ghostty package
# German translation for com.mitchellh.ghostty.
-# Copyright (C) 2025 Mitchell Hashimoto
+# Copyright (C) 2025 Mitchell Hashimoto, Ghostty contributors
# This file is distributed under the same license as the com.mitchellh.ghostty package.
# Robin Pfäffle <r@rpfaeffle.com>, 2025.
#
diff --git a/po/es_BO.UTF-8.po b/po/es_BO.UTF-8.po
index f3a62748a..077b7dfa1 100644
--- a/po/es_BO.UTF-8.po
+++ b/po/es_BO.UTF-8.po
@@ -1,5 +1,5 @@
# Spanish translations for com.mitchellh.ghostty package.
-# Copyright (C) 2025 Mitchell Hashimoto
+# Copyright (C) 2025 Mitchell Hashimoto, Ghostty contributors
# This file is distributed under the same license as the com.mitchellh.ghostty package.
# Miguel Peredo <miguelp@quientienemail.com>, 2025.
#
diff --git a/po/fr_FR.UTF-8.po b/po/fr_FR.UTF-8.po
index 4db72a23e..aef0d96ac 100644
--- a/po/fr_FR.UTF-8.po
+++ b/po/fr_FR.UTF-8.po
@@ -1,5 +1,5 @@
# French translations for com.mitchellh.ghostty package.
-# Copyright (C) 2025 Mitchell Hashimoto
+# Copyright (C) 2025 Mitchell Hashimoto, Ghostty contributors
# This file is distributed under the same license as the com.mitchellh.ghostty package.
# Kirwiisp <swiip__@hotmail.com>, 2025.
#
diff --git a/po/id_ID.UTF-8.po b/po/id_ID.UTF-8.po
index d5204d420..f82ec6197 100644
--- a/po/id_ID.UTF-8.po
+++ b/po/id_ID.UTF-8.po
@@ -1,5 +1,5 @@
# Indonesian translations for com.mitchellh.ghostty package.
-# Copyright (C) 2025 Mitchell Hashimoto
+# Copyright (C) 2025 Mitchell Hashimoto, Ghostty contributors
# This file is distributed under the same license as the com.mitchellh.ghostty package.
# Satrio Bayu Aji <halosatrio@gmail.com>, 2025.
#
diff --git a/po/ja_JP.UTF-8.po b/po/ja_JP.UTF-8.po
index e6e015f8a..73ddd9f5a 100644
--- a/po/ja_JP.UTF-8.po
+++ b/po/ja_JP.UTF-8.po
@@ -1,6 +1,6 @@
# Japanese translations for com.mitchellh.ghostty package
# com.mitchellh.ghostty パッケージに対する和訳.
-# Copyright (C) 2025 Mitchell Hashimoto
+# Copyright (C) 2025 Mitchell Hashimoto, Ghostty contributors
# This file is distributed under the same license as the com.mitchellh.ghostty package.
# Lon Sagisawa <lon@sagisawa.me>, 2025.
#
diff --git a/po/mk_MK.UTF-8.po b/po/mk_MK.UTF-8.po
index 39bb72b91..20a43572e 100644
--- a/po/mk_MK.UTF-8.po
+++ b/po/mk_MK.UTF-8.po
@@ -1,5 +1,5 @@
# Macedonian translations for com.mitchellh.ghostty package.
-# Copyright (C) 2025 Mitchell Hashimoto
+# Copyright (C) 2025 Mitchell Hashimoto, Ghostty contributors
# This file is distributed under the same license as the com.mitchellh.ghostty package.
# Andrej Daskalov <andrej.daskalov@gmail.com>, 2025.
#
diff --git a/po/nb_NO.UTF-8.po b/po/nb_NO.UTF-8.po
index ad76eea3d..045d47a80 100644
--- a/po/nb_NO.UTF-8.po
+++ b/po/nb_NO.UTF-8.po
@@ -1,5 +1,5 @@
# Norwegian Bokmal translations for com.mitchellh.ghostty package.
-# Copyright (C) 2025 Mitchell Hashimoto
+# Copyright (C) 2025 Mitchell Hashimoto, Ghostty contributors
# This file is distributed under the same license as the com.mitchellh.ghostty package.
# Hanna Rose <hanna@hanna.lol>, 2025.
# Uzair Aftab <uzaaft@outlook.com>, 2025.
@@ -63,25 +63,25 @@ msgstr "Last konfigurasjon på nytt"
#: 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 "Splitt opp"
+msgstr "Del oppover"
#: 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 "Splitt ned"
+msgstr "Del nedover"
#: 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 "Splitt venstre"
+msgstr "Del til venstre"
#: 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 "Splitt høyre"
+msgstr "Del til høyre"
#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:6
#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:6
@@ -107,7 +107,7 @@ msgstr "Nullstill"
#: 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 "Splitt"
+msgstr "Del vindu"
#: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:33
#: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:45
@@ -218,7 +218,7 @@ msgstr "Se åpne faner"
#: src/apprt/gtk/Window.zig:249
msgid "New Split"
-msgstr ""
+msgstr "Del opp vindu"
#: src/apprt/gtk/Window.zig:312
msgid ""
@@ -251,7 +251,7 @@ msgstr "Lukk fane?"
#: src/apprt/gtk/CloseDialog.zig:90
msgid "Close Split?"
-msgstr "Lukk splitt?"
+msgstr "Lukk delt vindu?"
#: src/apprt/gtk/CloseDialog.zig:96
msgid "All terminal sessions will be terminated."
diff --git a/po/nl_NL.UTF-8.po b/po/nl_NL.UTF-8.po
index 466116352..355bc4a57 100644
--- a/po/nl_NL.UTF-8.po
+++ b/po/nl_NL.UTF-8.po
@@ -1,5 +1,5 @@
# Dutch translations for com.mitchellh.ghostty package.
-# Copyright (C) 2025 Mitchell Hashimoto
+# Copyright (C) 2025 Mitchell Hashimoto, Ghostty contributors
# This file is distributed under the same license as the com.mitchellh.ghostty package.
# Nico Geesink <geesinknico@gmail.com>, 2025.
#
diff --git a/po/pl_PL.UTF-8.po b/po/pl_PL.UTF-8.po
index 22d2cd975..a68d56818 100644
--- a/po/pl_PL.UTF-8.po
+++ b/po/pl_PL.UTF-8.po
@@ -1,6 +1,6 @@
# Polish translations for com.mitchellh.ghostty package
# Polskie tłumaczenia dla pakietu com.mitchellh.ghostty.
-# Copyright (C) 2025 Mitchell Hashimoto
+# Copyright (C) 2025 Mitchell Hashimoto, Ghostty contributors
# This file is distributed under the same license as the com.mitchellh.ghostty package.
# Bartosz Sokorski <b.sokorski@gmail.com>, 2025.
#
diff --git a/po/pt_BR.UTF-8.po b/po/pt_BR.UTF-8.po
index f6d2f26a2..ba13f4460 100644
--- a/po/pt_BR.UTF-8.po
+++ b/po/pt_BR.UTF-8.po
@@ -1,6 +1,6 @@
# Portuguese translations for com.mitchellh.ghostty package
# Traduções em português brasileiro para o pacote com.mitchellh.ghostty.
-# Copyright (C) 2025 Mitchell Hashimoto
+# Copyright (C) 2025 Mitchell Hashimoto, Ghostty contributors
# This file is distributed under the same license as the com.mitchellh.ghostty package.
# Gustavo Peres <gsodevel@gmail.com>, 2025.
#
@@ -9,8 +9,8 @@ msgstr ""
"Project-Id-Version: com.mitchellh.ghostty\n"
"Report-Msgid-Bugs-To: m@mitchellh.com\n"
"POT-Creation-Date: 2025-04-22 08:57-0700\n"
-"PO-Revision-Date: 2025-03-28 11:04-0300\n"
-"Last-Translator: Gustavo Peres <gsodevel@gmail.com>\n"
+"PO-Revision-Date: 2025-06-20 10:19-0300\n"
+"Last-Translator: Mário Victor Ribeiro Silva <mariovictorrs@gmail.com>\n"
"Language-Team: Brazilian Portuguese <ldpbr-translation@lists.sourceforge."
"net>\n"
"Language: pt_BR\n"
@@ -217,7 +217,7 @@ msgstr "Visualizar abas abertas"
#: src/apprt/gtk/Window.zig:249
msgid "New Split"
-msgstr ""
+msgstr "Nova divisão"
#: src/apprt/gtk/Window.zig:312
msgid ""
diff --git a/po/ru_RU.UTF-8.po b/po/ru_RU.UTF-8.po
index 9e9cf8077..0cb533de7 100644
--- a/po/ru_RU.UTF-8.po
+++ b/po/ru_RU.UTF-8.po
@@ -1,6 +1,6 @@
# Russian translations for com.mitchellh.ghostty package
# Русские переводы для пакета com.mitchellh.ghostty.
-# Copyright (C) 2025 Mitchell Hashimoto
+# Copyright (C) 2025 Mitchell Hashimoto, Ghostty contributors
# This file is distributed under the same license as the com.mitchellh.ghostty package.
# blackzeshi <sergey_zhuzhgov@mail.ru>, 2025.
#
diff --git a/po/tr_TR.UTF-8.po b/po/tr_TR.UTF-8.po
index 3de70d61c..5d761f6a4 100644
--- a/po/tr_TR.UTF-8.po
+++ b/po/tr_TR.UTF-8.po
@@ -1,5 +1,5 @@
# Turkish translations for com.mitchellh.ghostty package.
-# Copyright (C) 2025 Mitchell Hashimoto
+# Copyright (C) 2025 Mitchell Hashimoto, Ghostty contributors
# This file is distributed under the same license as the com.mitchellh.ghostty package.
# Emir SARI <emir_sari@icloud.com>, 2025.
#
diff --git a/po/uk_UA.UTF-8.po b/po/uk_UA.UTF-8.po
index 5a264b537..bde975fc4 100644
--- a/po/uk_UA.UTF-8.po
+++ b/po/uk_UA.UTF-8.po
@@ -1,5 +1,5 @@
# Ukrainian translations for com.mitchellh.ghostty package.
-# Copyright (C) 2025 Mitchell Hashimoto
+# Copyright (C) 2025 Mitchell Hashimoto, Ghostty contributors
# This file is distributed under the same license as the com.mitchellh.ghostty package.
# Danylo Zalizchuk <danilmail0110@gmail.com>, 2025.
#
diff --git a/po/zh_CN.UTF-8.po b/po/zh_CN.UTF-8.po
index ee2c51362..17a6dc921 100644
--- a/po/zh_CN.UTF-8.po
+++ b/po/zh_CN.UTF-8.po
@@ -1,6 +1,6 @@
# Chinese translations for com.mitchellh.ghostty package
# com.mitchellh.ghostty 软件包的简体中文翻译.
-# Copyright (C) 2025 Mitchell Hashimoto
+# Copyright (C) 2025 Mitchell Hashimoto, Ghostty contributors
# This file is distributed under the same license as the com.mitchellh.ghostty package.
# Leah <hi@pluie.me>, 2025.
#
@@ -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 <hi@pluie.me>\n"
"Language-Team: Chinese (simplified) <i18n-zh@googlegroups.com>\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 b57411a6c..df8d6ae53 100644
--- a/snap/snapcraft.yaml
+++ b/snap/snapcraft.yaml
@@ -72,17 +72,16 @@ parts:
build-packages:
- libgtk-4-dev
- libadwaita-1-dev
- # TODO: Add when the Snap is updated to Ubuntu 24.10+
- # - gtk4-layer-shell
- libxml2-utils
- 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/
- sed -i 's|Icon=com.mitchellh.ghostty|Icon=/snap/ghostty/current/share/icons/hicolor/512x512/apps/com.mitchellh.ghostty.png|g' $CRAFT_PART_INSTALL/share/applications/com.mitchellh.ghostty.desktop
+ sed -i 's|Icon=com.mitchellh.ghostty|Icon=${SNAP}/share/icons/hicolor/512x512/apps/com.mitchellh.ghostty.png|g' $CRAFT_PART_INSTALL/share/applications/com.mitchellh.ghostty.desktop
libs:
plugin: nil
diff --git a/src/App.zig b/src/App.zig
index 005b745a6..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);
}
@@ -445,6 +455,10 @@ pub fn performAction(
.toggle_quick_terminal => _ = try rt_app.performAction(.app, .toggle_quick_terminal, {}),
.toggle_visibility => _ = try rt_app.performAction(.app, .toggle_visibility, {}),
.check_for_updates => _ = try rt_app.performAction(.app, .check_for_updates, {}),
+ .show_gtk_inspector => _ = try rt_app.performAction(.app, .show_gtk_inspector, {}),
+ .undo => _ = try rt_app.performAction(.app, .undo, {}),
+
+ .redo => _ = try rt_app.performAction(.app, .redo, {}),
}
}
diff --git a/src/Command.zig b/src/Command.zig
index e17c1b370..7ed026efe 100644
--- a/src/Command.zig
+++ b/src/Command.zig
@@ -323,7 +323,7 @@ fn setupFd(src: File.Handle, target: i32) !void {
}
}
},
- .ios, .macos => {
+ .freebsd, .ios, .macos => {
// Mac doesn't support dup3 so we use dup2. We purposely clear
// CLO_ON_EXEC for this fd.
const flags = try posix.fcntl(src, posix.F.GETFD, 0);
@@ -370,7 +370,7 @@ pub fn wait(self: Command, block: bool) !Exit {
}
};
- return Exit.init(res.status);
+ return .init(res.status);
}
/// Sets command->data to data.
diff --git a/src/Surface.zig b/src/Surface.zig
index f9e232340..5acec8c00 100644
--- a/src/Surface.zig
+++ b/src/Surface.zig
@@ -160,7 +160,7 @@ pub const InputEffect = enum {
/// Mouse state for the surface.
const Mouse = struct {
/// The last tracked mouse button state by button.
- click_state: [input.MouseButton.max]input.MouseButtonState = .{.release} ** input.MouseButton.max,
+ click_state: [input.MouseButton.max]input.MouseButtonState = @splat(.release),
/// The last mods state when the last mouse button (whatever it was) was
/// pressed or release.
@@ -237,6 +237,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 +256,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 +303,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 +322,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,
@@ -463,11 +467,12 @@ pub fn init(
// Create our terminal grid with the initial size
const app_mailbox: App.Mailbox = .{ .rt_app = rt_app, .mailbox = &app.mailbox };
var renderer_impl = try Renderer.init(alloc, .{
- .config = try Renderer.DerivedConfig.init(alloc, config),
+ .config = try .init(alloc, config),
.font_grid = font_grid,
.size = size,
.surface_mailbox = .{ .surface = self, .app = app_mailbox },
.rt_surface = rt_surface,
+ .thread = &self.renderer_thread,
});
errdefer renderer_impl.deinit();
@@ -545,7 +550,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
@@ -726,7 +731,9 @@ pub fn close(self: *Surface) void {
/// is in the middle of animation (such as a resize, etc.) or when
/// the render timer is managed manually by the apprt.
pub fn draw(self: *Surface) !void {
- try self.renderer_thread.draw_now.notify();
+ // Renderers are required to support `drawFrame` being called from
+ // the main thread, so that they can update contents during resize.
+ try self.renderer.drawFrame(true);
}
/// Activate the inspector. This will begin collecting inspection data.
@@ -908,11 +915,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,6 +948,136 @@ pub fn handleMessage(self: *Surface, msg: Message) !void {
}
}
+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 {
{
@@ -1292,6 +1425,133 @@ fn recomputeInitialSize(
) catch return error.AppActionFailed;
}
+/// Represents text read from the terminal and some metadata about it
+/// that is often useful to apprts.
+pub const Text = struct {
+ /// The text that was read from the terminal.
+ text: [:0]const u8,
+
+ /// The viewport information about this text, if it is visible in
+ /// the viewport.
+ ///
+ /// NOTE(mitchellh): This will only be non-null currently if the entirety
+ /// of the selection is contained within the viewport. We don't have a
+ /// use case currently for partial bounds but we should support this
+ /// eventually.
+ viewport: ?Viewport = null,
+
+ pub const Viewport = struct {
+ /// The top-left corner of the selection in pixels within the viewport.
+ tl_px_x: f64,
+ tl_px_y: f64,
+
+ /// The linear offset of the start of the selection and the length.
+ /// This is "linear" in the sense that it is the offset in the
+ /// flattened viewport as a single array of text.
+ offset_start: u32,
+ offset_len: u32,
+ };
+
+ pub fn deinit(self: *Text, alloc: Allocator) void {
+ alloc.free(self.text);
+ }
+};
+
+/// Grab the value of text at the given selection point. Note that the
+/// selection structure is used as a way to determine the area of the
+/// screen to read from, it doesn't have to match the user's current
+/// selection state.
+///
+/// The returned value contains allocated data and must be deinitialized.
+pub fn dumpText(
+ self: *Surface,
+ alloc: Allocator,
+ sel: terminal.Selection,
+) !Text {
+ self.renderer_state.mutex.lock();
+ defer self.renderer_state.mutex.unlock();
+ return try self.dumpTextLocked(alloc, sel);
+}
+
+/// Same as `dumpText` but assumes the renderer state mutex is already
+/// held.
+pub fn dumpTextLocked(
+ self: *Surface,
+ alloc: Allocator,
+ sel: terminal.Selection,
+) !Text {
+ // Read out the text
+ const text = try self.io.terminal.screen.selectionString(alloc, .{
+ .sel = sel,
+ .trim = false,
+ });
+ errdefer alloc.free(text);
+
+ // Calculate our viewport info if we can.
+ const vp: ?Text.Viewport = viewport: {
+ // If our tl or br is not in the viewport then we don't
+ // have a viewport. One day we should extend this to support
+ // partial selections that are in the viewport.
+ const tl_pt = self.io.terminal.screen.pages.pointFromPin(
+ .viewport,
+ sel.topLeft(&self.io.terminal.screen),
+ ) orelse break :viewport null;
+ const br_pt = self.io.terminal.screen.pages.pointFromPin(
+ .viewport,
+ sel.bottomRight(&self.io.terminal.screen),
+ ) orelse break :viewport null;
+ const tl_coord = tl_pt.coord();
+ const br_coord = br_pt.coord();
+
+ // Our sizes are all scaled so we need to send the unscaled values back.
+ const content_scale = self.rt_surface.getContentScale() catch .{ .x = 1, .y = 1 };
+ const x: f64 = x: {
+ // Simple x * cell width gives the left
+ var x: f64 = @floatFromInt(tl_coord.x * self.size.cell.width);
+
+ // Add padding
+ x += @floatFromInt(self.size.padding.left);
+
+ // Scale
+ x /= content_scale.x;
+
+ break :x x;
+ };
+ const y: f64 = y: {
+ // Simple y * cell height gives the top
+ var y: f64 = @floatFromInt(tl_coord.y * self.size.cell.height);
+
+ // We want the text baseline
+ y += @floatFromInt(self.size.cell.height);
+ y -= @floatFromInt(self.font_metrics.cell_baseline);
+
+ // Add padding
+ y += @floatFromInt(self.size.padding.top);
+
+ // Scale
+ y /= content_scale.y;
+
+ break :y y;
+ };
+
+ // Utilize viewport sizing to convert to offsets
+ const start = tl_coord.y * self.io.terminal.screen.pages.cols + tl_coord.x;
+ const end = br_coord.y * self.io.terminal.screen.pages.cols + br_coord.x;
+
+ break :viewport .{
+ .tl_px_x = x,
+ .tl_px_y = y,
+ .offset_start = start,
+ .offset_len = end - start,
+ };
+ };
+
+ return .{
+ .text = text,
+ .viewport = vp,
+ };
+}
+
/// Returns true if the terminal has a selection.
pub fn hasSelection(self: *const Surface) bool {
self.renderer_state.mutex.lock();
@@ -1823,6 +2083,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).
@@ -2069,12 +2337,18 @@ fn maybeHandleBinding(
break :performed try self.performBindingAction(action);
};
- // If we performed an action and it was a closing action,
- // our "self" pointer is not safe to use anymore so we need to
- // just exit immediately.
- if (performed and closingAction(action)) {
- log.debug("key binding is a closing binding, halting key event processing", .{});
- return .closed;
+ if (performed) {
+ // If we performed an action and it was a closing action,
+ // our "self" pointer is not safe to use anymore so we need to
+ // just exit immediately.
+ if (closingAction(action)) {
+ log.debug("key binding is a closing binding, halting key event processing", .{});
+ return .closed;
+ }
+
+ // If our action was "ignore" then we return the special input
+ // effect of "ignored".
+ if (action == .ignore) return .ignored;
}
// If we have the performable flag and the action was not performed,
@@ -2958,15 +3232,33 @@ 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) {
+ // 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});
+ }
}
}
@@ -3102,12 +3394,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();
}
},
@@ -3116,7 +3412,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();
}
},
@@ -3128,7 +3424,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();
}
},
@@ -3413,7 +3709,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();
}
}
@@ -3632,13 +3928,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,
@@ -3670,171 +3966,168 @@ fn dragLeftClickTriple(
} else {
sel.endPtr().* = line.end();
}
- try self.setSelection(sel);
+ try self.io.terminal.screen.select(sel);
}
fn dragLeftClickSingle(
self: *Surface,
drag_pin: terminal.Pin,
- xpos: f64,
+ drag_x: f64,
) !void {
- // NOTE(mitchellh): This logic super sucks. There has to be an easier way
- // to calculate this, but this is good for a v1. Selection isn't THAT
- // common so its not like this performance heavy code is running that
- // often.
- // TODO: unit test this, this logic sucks
-
- // If we were selecting, and we switched directions, then we restart
- // calculations because it forces us to reconsider if the first cell is
- // selected.
- self.checkResetSelSwitch(drag_pin);
-
- // Our logic for determining if the starting cell is selected:
+ // This logic is in a separate function so that it can be unit tested.
+ try self.io.terminal.screen.select(mouseSelection(
+ self.mouse.left_click_pin.?.*,
+ drag_pin,
+ @intFromFloat(@max(0.0, self.mouse.left_click_xpos)),
+ @intFromFloat(@max(0.0, drag_x)),
+ self.mouse.mods,
+ self.size,
+ ));
+}
+
+/// Calculates the appropriate selection given pins and pixel x positions for
+/// the click point and the drag point, as well as mouse mods and screen size.
+fn mouseSelection(
+ click_pin: terminal.Pin,
+ drag_pin: terminal.Pin,
+ click_x: u32,
+ drag_x: u32,
+ mods: input.Mods,
+ size: rendererpkg.Size,
+) ?terminal.Selection {
+ // Explanation:
//
- // - The "xboundary" is 60% the width of a cell from the left. We choose
- // 60% somewhat arbitrarily based on feeling.
- // - If we started our click left of xboundary, backwards selections
- // can NEVER select the current char.
- // - If we started our click right of xboundary, backwards selections
- // ALWAYS selected the current char, but we must move the cursor
- // left of the xboundary.
- // - Inverted logic for forwards selections.
+ // # Normal selections
//
+ // ## Left-to-right selections
+ // - The clicked cell is included if it was clicked to the left of its
+ // threshold point and the drag location is right of the threshold point.
+ // - The cell under the cursor (the "drag cell") is included if the drag
+ // location is right of its threshold point.
+ //
+ // ## Right-to-left selections
+ // - The clicked cell is included if it was clicked to the right of its
+ // threshold point and the drag location is left of the threshold point.
+ // - The cell under the cursor (the "drag cell") is included if the drag
+ // location is left of its threshold point.
+ //
+ // # Rectangular selections
+ //
+ // Rectangular selections are handled similarly, except that
+ // entire columns are considered rather than individual cells.
+
+ // We only include cells in the selection if the threshold point lies
+ // between the start and end points of the selection. A threshold of
+ // 60% of the cell width was chosen empirically because it felt good.
+ const threshold_point: u32 = @intFromFloat(@round(
+ @as(f64, @floatFromInt(size.cell.width)) * 0.6,
+ ));
- // Our clicking point
- const click_pin = self.mouse.left_click_pin.?.*;
-
- // the boundary point at which we consider selection or non-selection
- const cell_width_f64: f64 = @floatFromInt(self.size.cell.width);
- const cell_xboundary = cell_width_f64 * 0.6;
-
- // first xpos of the clicked cell adjusted for padding
- const left_padding_f64: f64 = @as(f64, @floatFromInt(self.size.padding.left));
- const cell_xstart = @as(f64, @floatFromInt(click_pin.x)) * cell_width_f64;
- const cell_start_xpos = self.mouse.left_click_xpos - cell_xstart - left_padding_f64;
-
- // If this is the same cell, then we only start the selection if weve
- // moved past the boundary point the opposite direction from where we
- // started.
- if (click_pin.eql(drag_pin)) {
- // Ensuring to adjusting the cursor position for padding
- const cell_xpos = xpos - cell_xstart - left_padding_f64;
- const selected: bool = if (cell_start_xpos < cell_xboundary)
- cell_xpos >= cell_xboundary
- else
- cell_xpos < cell_xboundary;
+ // We use this to clamp the pixel positions below.
+ const max_x = size.grid().columns * size.cell.width - 1;
- try self.setSelection(if (selected) terminal.Selection.init(
- drag_pin,
- drag_pin,
- SurfaceMouse.isRectangleSelectState(self.mouse.mods),
- ) else null);
+ // We need to know how far across in the cell the drag pos is, so
+ // we subtract the padding and then take it modulo the cell width.
+ const drag_x_frac = @min(max_x, drag_x -| size.padding.left) % size.cell.width;
- return;
- }
+ // We figure out the fractional part of the click x position similarly.
+ const click_x_frac = @min(max_x, click_x -| size.padding.left) % size.cell.width;
- // If this is a different cell and we haven't started selection,
- // we determine the starting cell first.
- if (self.io.terminal.screen.selection == null) {
- // - If we're moving to a point before the start, then we select
- // the starting cell if we started after the boundary, else
- // we start selection of the prior cell.
- // - Inverse logic for a point after the start.
- const start: terminal.Pin = if (dragLeftClickBefore(
- drag_pin,
- click_pin,
- self.mouse.mods,
- )) start: {
- if (cell_start_xpos >= cell_xboundary) break :start click_pin;
- if (click_pin.x > 0) break :start click_pin.left(1);
- var start = click_pin.up(1) orelse click_pin;
- start.x = self.io.terminal.screen.pages.cols - 1;
- break :start start;
- } else start: {
- if (cell_start_xpos < cell_xboundary) break :start click_pin;
- if (click_pin.x < self.io.terminal.screen.pages.cols - 1)
- break :start click_pin.right(1);
- var start = click_pin.down(1) orelse click_pin;
- start.x = 0;
- break :start start;
- };
+ // Whether or not this is a rectangular selection.
+ const rectangle_selection = SurfaceMouse.isRectangleSelectState(mods);
- try self.setSelection(terminal.Selection.init(
- start,
- drag_pin,
- SurfaceMouse.isRectangleSelectState(self.mouse.mods),
- ));
- return;
- }
+ // Whether the click pin and drag pin are equal.
+ const same_pin = drag_pin.eql(click_pin);
- // TODO: detect if selection point is passed the point where we've
- // actually written data before and disallow it.
-
- // We moved! Set the selection end point. The start point should be
- // set earlier.
- assert(self.io.terminal.screen.selection != null);
- const sel = self.io.terminal.screen.selection.?;
- try self.setSelection(terminal.Selection.init(
- sel.start(),
- drag_pin,
- sel.rectangle,
- ));
-}
+ // Whether or not the end point of our selection is before the start point.
+ const end_before_start = ebs: {
+ if (same_pin) {
+ break :ebs drag_x_frac < click_x_frac;
+ }
-// Resets the selection if we switched directions, depending on the select
-// mode. See dragLeftClickSingle for more details.
-fn checkResetSelSwitch(
- self: *Surface,
- drag_pin: terminal.Pin,
-) void {
- const screen = &self.io.terminal.screen;
- const sel = screen.selection orelse return;
- const sel_start = sel.start();
- const sel_end = sel.end();
-
- var reset: bool = false;
- if (sel.rectangle) {
- // When we're in rectangle mode, we reset the selection relative to
- // the click point depending on the selection mode we're in, with
- // the exception of single-column selections, which we always reset
- // on if we drift.
- if (sel_start.x == sel_end.x) {
- reset = drag_pin.x != sel_start.x;
- } else {
- reset = switch (sel.order(screen)) {
- .forward => drag_pin.x < sel_start.x or drag_pin.before(sel_start),
- .reverse => drag_pin.x > sel_start.x or sel_start.before(drag_pin),
- .mirrored_forward => drag_pin.x > sel_start.x or drag_pin.before(sel_start),
- .mirrored_reverse => drag_pin.x < sel_start.x or sel_start.before(drag_pin),
+ // Special handling for rectangular selections, we only use x position.
+ if (rectangle_selection) {
+ break :ebs switch (std.math.order(drag_pin.x, click_pin.x)) {
+ .eq => drag_x_frac < click_x_frac,
+ .lt => true,
+ .gt => false,
};
}
- } else {
- // Normal select uses simpler logic that is just based on the
- // selection start/end.
- reset = if (sel_end.before(sel_start))
- sel_start.before(drag_pin)
- else
- drag_pin.before(sel_start);
- }
- // Nullifying a selection can't fail.
- if (reset) self.setSelection(null) catch unreachable;
-}
+ break :ebs drag_pin.before(click_pin);
+ };
-// Handles how whether or not the drag screen point is before the click point.
-// When we are in rectangle select, we only interpret the x axis to determine
-// where to start the selection (before or after the click point). See
-// dragLeftClickSingle for more details.
-fn dragLeftClickBefore(
- drag_pin: terminal.Pin,
- click_pin: terminal.Pin,
- mods: input.Mods,
-) bool {
- if (mods.ctrlOrSuper() and mods.alt) {
- return drag_pin.x < click_pin.x;
+ // Whether or not the the click pin cell
+ // should be included in the selection.
+ const include_click_cell = if (end_before_start)
+ click_x_frac >= threshold_point
+ else
+ click_x_frac < threshold_point;
+
+ // Whether or not the the drag pin cell
+ // should be included in the selection.
+ const include_drag_cell = if (end_before_start)
+ drag_x_frac < threshold_point
+ else
+ drag_x_frac >= threshold_point;
+
+ // If the click cell should be included in the selection then it's the
+ // start, otherwise we get the previous or next cell to it depending on
+ // the type and direction of the selection.
+ const start_pin =
+ if (include_click_cell)
+ click_pin
+ else if (end_before_start)
+ if (rectangle_selection)
+ click_pin.leftClamp(1)
+ else
+ click_pin.leftWrap(1) orelse click_pin
+ else if (rectangle_selection)
+ click_pin.rightClamp(1)
+ else
+ click_pin.rightWrap(1) orelse click_pin;
+
+ // Likewise for the end pin with the drag cell.
+ const end_pin =
+ if (include_drag_cell)
+ drag_pin
+ else if (end_before_start)
+ if (rectangle_selection)
+ drag_pin.rightClamp(1)
+ else
+ drag_pin.rightWrap(1) orelse drag_pin
+ else if (rectangle_selection)
+ drag_pin.leftClamp(1)
+ else
+ drag_pin.leftWrap(1) orelse drag_pin;
+
+ // If the click cell is the same as the drag cell and the click cell
+ // shouldn't be included, or if the cells are adjacent such that the
+ // start or end pin becomes the other cell, and that cell should not
+ // be included, then we have no selection, so we set it to null.
+ //
+ // If in rectangular selection mode, we compare columns as well.
+ //
+ // TODO(qwerasd): this can/should probably be refactored, it's a bit
+ // repetitive and does excess work in rectangle mode.
+ if ((!include_click_cell and same_pin) or
+ (!include_click_cell and rectangle_selection and click_pin.x == drag_pin.x) or
+ (!include_click_cell and end_pin.eql(click_pin)) or
+ (!include_click_cell and rectangle_selection and end_pin.x == click_pin.x) or
+ (!include_drag_cell and start_pin.eql(drag_pin)) or
+ (!include_drag_cell and rectangle_selection and start_pin.x == drag_pin.x))
+ {
+ return null;
}
- return drag_pin.before(click_pin);
+ // TODO: Clamp selection to the screen area, don't
+ // let it extend past the last written row.
+
+ return .init(
+ start_pin,
+ end_pin,
+ rectangle_selection,
+ );
}
/// Call to notify Ghostty that the color scheme for the terminal has
@@ -3920,6 +4213,21 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
.{ .parent = self },
),
+ // Undo and redo both support both surface and app targeting.
+ // If we are triggering on a surface then we perform the
+ // action with the surface target.
+ .undo => return try self.rt_app.performAction(
+ .{ .surface = self },
+ .undo,
+ {},
+ ),
+
+ .redo => return try self.rt_app.performAction(
+ .{ .surface = self },
+ .redo,
+ {},
+ ),
+
else => try self.app.performAction(
self.rt_app,
action.scoped(.app).?,
@@ -4537,6 +4845,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,
@@ -4819,3 +5132,430 @@ fn presentSurface(self: *Surface) !void {
{},
);
}
+
+/// Utility function for the unit tests for mouse selection logic.
+///
+/// Tests a click and drag on a 10x5 cell grid, x positions are given in
+/// fractional cells, e.g. 3.1 would be 10% through the cell at x = 3.
+///
+/// NOTE: The size tested with has 10px wide cells, meaning only one digit
+/// after the decimal place has any meaning, e.g. 3.14 is equal to 3.1.
+///
+/// The provided start_x/y and end_x/y are the expected start and end points
+/// of the resulting selection.
+fn testMouseSelection(
+ click_x: f64,
+ click_y: u32,
+ drag_x: f64,
+ drag_y: u32,
+ start_x: terminal.size.CellCountInt,
+ start_y: u32,
+ end_x: terminal.size.CellCountInt,
+ end_y: u32,
+ rect: bool,
+) !void {
+ assert(builtin.is_test);
+
+ // Our screen size is 10x5 cells that are
+ // 10x20 px, with 5px padding on all sides.
+ const size: rendererpkg.Size = .{
+ .cell = .{ .width = 10, .height = 20 },
+ .padding = .{ .left = 5, .top = 5, .right = 5, .bottom = 5 },
+ .screen = .{ .width = 110, .height = 110 },
+ };
+ var screen = try terminal.Screen.init(std.testing.allocator, 10, 5, 0);
+ defer screen.deinit();
+
+ // We hold both ctrl and alt for rectangular
+ // select so that this test is platform agnostic.
+ const mods: input.Mods = .{
+ .ctrl = rect,
+ .alt = rect,
+ };
+
+ try std.testing.expectEqual(rect, SurfaceMouse.isRectangleSelectState(mods));
+
+ const click_pin = screen.pages.pin(.{
+ .viewport = .{ .x = @intFromFloat(@floor(click_x)), .y = click_y },
+ }) orelse unreachable;
+ const drag_pin = screen.pages.pin(.{
+ .viewport = .{ .x = @intFromFloat(@floor(drag_x)), .y = drag_y },
+ }) orelse unreachable;
+
+ const cell_width_f64: f64 = @floatFromInt(size.cell.width);
+ const click_x_pos: u32 =
+ @as(u32, @intFromFloat(@floor(click_x * cell_width_f64))) +
+ size.padding.left;
+ const drag_x_pos: u32 =
+ @as(u32, @intFromFloat(@floor(drag_x * cell_width_f64))) +
+ size.padding.left;
+
+ const start_pin = screen.pages.pin(.{
+ .viewport = .{ .x = start_x, .y = start_y },
+ }) orelse unreachable;
+ const end_pin = screen.pages.pin(.{
+ .viewport = .{ .x = end_x, .y = end_y },
+ }) orelse unreachable;
+
+ try std.testing.expectEqualDeep(terminal.Selection{
+ .bounds = .{ .untracked = .{
+ .start = start_pin,
+ .end = end_pin,
+ } },
+ .rectangle = rect,
+ }, mouseSelection(
+ click_pin,
+ drag_pin,
+ click_x_pos,
+ drag_x_pos,
+ mods,
+ size,
+ ));
+}
+
+/// Like `testMouseSelection` but checks that the resulting selection is null.
+///
+/// See `testMouseSelection` for more details.
+fn testMouseSelectionIsNull(
+ click_x: f64,
+ click_y: u32,
+ drag_x: f64,
+ drag_y: u32,
+ rect: bool,
+) !void {
+ assert(builtin.is_test);
+
+ // Our screen size is 10x5 cells that are
+ // 10x20 px, with 5px padding on all sides.
+ const size: rendererpkg.Size = .{
+ .cell = .{ .width = 10, .height = 20 },
+ .padding = .{ .left = 5, .top = 5, .right = 5, .bottom = 5 },
+ .screen = .{ .width = 110, .height = 110 },
+ };
+ var screen = try terminal.Screen.init(std.testing.allocator, 10, 5, 0);
+ defer screen.deinit();
+
+ // We hold both ctrl and alt for rectangular
+ // select so that this test is platform agnostic.
+ const mods: input.Mods = .{
+ .ctrl = rect,
+ .alt = rect,
+ };
+
+ try std.testing.expectEqual(rect, SurfaceMouse.isRectangleSelectState(mods));
+
+ const click_pin = screen.pages.pin(.{
+ .viewport = .{ .x = @intFromFloat(@floor(click_x)), .y = click_y },
+ }) orelse unreachable;
+ const drag_pin = screen.pages.pin(.{
+ .viewport = .{ .x = @intFromFloat(@floor(drag_x)), .y = drag_y },
+ }) orelse unreachable;
+
+ const cell_width_f64: f64 = @floatFromInt(size.cell.width);
+ const click_x_pos: u32 =
+ @as(u32, @intFromFloat(@floor(click_x * cell_width_f64))) +
+ size.padding.left;
+ const drag_x_pos: u32 =
+ @as(u32, @intFromFloat(@floor(drag_x * cell_width_f64))) +
+ size.padding.left;
+
+ try std.testing.expectEqual(
+ null,
+ mouseSelection(
+ click_pin,
+ drag_pin,
+ click_x_pos,
+ drag_x_pos,
+ mods,
+ size,
+ ),
+ );
+}
+
+test "Surface: selection logic" {
+ // We disable format to make these easier to
+ // read by pairing sets of coordinates per line.
+ // zig fmt: off
+
+ // -- LTR
+ // single cell selection
+ try testMouseSelection(
+ 3.0, 3, // click
+ 3.9, 3, // drag
+ 3, 3, // expected start
+ 3, 3, // expected end
+ false, // regular selection
+ );
+ // including click and drag pin cells
+ try testMouseSelection(
+ 3.0, 3, // click
+ 5.9, 3, // drag
+ 3, 3, // expected start
+ 5, 3, // expected end
+ false, // regular selection
+ );
+ // including click pin cell but not drag pin cell
+ try testMouseSelection(
+ 3.0, 3, // click
+ 5.0, 3, // drag
+ 3, 3, // expected start
+ 4, 3, // expected end
+ false, // regular selection
+ );
+ // including drag pin cell but not click pin cell
+ try testMouseSelection(
+ 3.9, 3, // click
+ 5.9, 3, // drag
+ 4, 3, // expected start
+ 5, 3, // expected end
+ false, // regular selection
+ );
+ // including neither click nor drag pin cells
+ try testMouseSelection(
+ 3.9, 3, // click
+ 5.0, 3, // drag
+ 4, 3, // expected start
+ 4, 3, // expected end
+ false, // regular selection
+ );
+ // empty selection (single cell on only left half)
+ try testMouseSelectionIsNull(
+ 3.0, 3, // click
+ 3.1, 3, // drag
+ false, // regular selection
+ );
+ // empty selection (single cell on only right half)
+ try testMouseSelectionIsNull(
+ 3.8, 3, // click
+ 3.9, 3, // drag
+ false, // regular selection
+ );
+ // empty selection (between two cells, not crossing threshold)
+ try testMouseSelectionIsNull(
+ 3.9, 3, // click
+ 4.0, 3, // drag
+ false, // regular selection
+ );
+
+ // -- RTL
+ // single cell selection
+ try testMouseSelection(
+ 3.9, 3, // click
+ 3.0, 3, // drag
+ 3, 3, // expected start
+ 3, 3, // expected end
+ false, // regular selection
+ );
+ // including click and drag pin cells
+ try testMouseSelection(
+ 5.9, 3, // click
+ 3.0, 3, // drag
+ 5, 3, // expected start
+ 3, 3, // expected end
+ false, // regular selection
+ );
+ // including click pin cell but not drag pin cell
+ try testMouseSelection(
+ 5.9, 3, // click
+ 3.9, 3, // drag
+ 5, 3, // expected start
+ 4, 3, // expected end
+ false, // regular selection
+ );
+ // including drag pin cell but not click pin cell
+ try testMouseSelection(
+ 5.0, 3, // click
+ 3.0, 3, // drag
+ 4, 3, // expected start
+ 3, 3, // expected end
+ false, // regular selection
+ );
+ // including neither click nor drag pin cells
+ try testMouseSelection(
+ 5.0, 3, // click
+ 3.9, 3, // drag
+ 4, 3, // expected start
+ 4, 3, // expected end
+ false, // regular selection
+ );
+ // empty selection (single cell on only left half)
+ try testMouseSelectionIsNull(
+ 3.1, 3, // click
+ 3.0, 3, // drag
+ false, // regular selection
+ );
+ // empty selection (single cell on only right half)
+ try testMouseSelectionIsNull(
+ 3.9, 3, // click
+ 3.8, 3, // drag
+ false, // regular selection
+ );
+ // empty selection (between two cells, not crossing threshold)
+ try testMouseSelectionIsNull(
+ 4.0, 3, // click
+ 3.9, 3, // drag
+ false, // regular selection
+ );
+
+ // -- Wrapping
+ // LTR, wrap excluded cells
+ try testMouseSelection(
+ 9.9, 2, // click
+ 0.0, 4, // drag
+ 0, 3, // expected start
+ 9, 3, // expected end
+ false, // regular selection
+ );
+ // RTL, wrap excluded cells
+ try testMouseSelection(
+ 0.0, 4, // click
+ 9.9, 2, // drag
+ 9, 3, // expected start
+ 0, 3, // expected end
+ false, // regular selection
+ );
+}
+
+test "Surface: rectangle selection logic" {
+ // We disable format to make these easier to
+ // read by pairing sets of coordinates per line.
+ // zig fmt: off
+
+ // -- LTR
+ // single column selection
+ try testMouseSelection(
+ 3.0, 2, // click
+ 3.9, 4, // drag
+ 3, 2, // expected start
+ 3, 4, // expected end
+ true, //rectangle selection
+ );
+ // including click and drag pin columns
+ try testMouseSelection(
+ 3.0, 2, // click
+ 5.9, 4, // drag
+ 3, 2, // expected start
+ 5, 4, // expected end
+ true, //rectangle selection
+ );
+ // including click pin column but not drag pin column
+ try testMouseSelection(
+ 3.0, 2, // click
+ 5.0, 4, // drag
+ 3, 2, // expected start
+ 4, 4, // expected end
+ true, //rectangle selection
+ );
+ // including drag pin column but not click pin column
+ try testMouseSelection(
+ 3.9, 2, // click
+ 5.9, 4, // drag
+ 4, 2, // expected start
+ 5, 4, // expected end
+ true, //rectangle selection
+ );
+ // including neither click nor drag pin columns
+ try testMouseSelection(
+ 3.9, 2, // click
+ 5.0, 4, // drag
+ 4, 2, // expected start
+ 4, 4, // expected end
+ true, //rectangle selection
+ );
+ // empty selection (single column on only left half)
+ try testMouseSelectionIsNull(
+ 3.0, 2, // click
+ 3.1, 4, // drag
+ true, //rectangle selection
+ );
+ // empty selection (single column on only right half)
+ try testMouseSelectionIsNull(
+ 3.8, 2, // click
+ 3.9, 4, // drag
+ true, //rectangle selection
+ );
+ // empty selection (between two columns, not crossing threshold)
+ try testMouseSelectionIsNull(
+ 3.9, 2, // click
+ 4.0, 4, // drag
+ true, //rectangle selection
+ );
+
+ // -- RTL
+ // single column selection
+ try testMouseSelection(
+ 3.9, 2, // click
+ 3.0, 4, // drag
+ 3, 2, // expected start
+ 3, 4, // expected end
+ true, //rectangle selection
+ );
+ // including click and drag pin columns
+ try testMouseSelection(
+ 5.9, 2, // click
+ 3.0, 4, // drag
+ 5, 2, // expected start
+ 3, 4, // expected end
+ true, //rectangle selection
+ );
+ // including click pin column but not drag pin column
+ try testMouseSelection(
+ 5.9, 2, // click
+ 3.9, 4, // drag
+ 5, 2, // expected start
+ 4, 4, // expected end
+ true, //rectangle selection
+ );
+ // including drag pin column but not click pin column
+ try testMouseSelection(
+ 5.0, 2, // click
+ 3.0, 4, // drag
+ 4, 2, // expected start
+ 3, 4, // expected end
+ true, //rectangle selection
+ );
+ // including neither click nor drag pin columns
+ try testMouseSelection(
+ 5.0, 2, // click
+ 3.9, 4, // drag
+ 4, 2, // expected start
+ 4, 4, // expected end
+ true, //rectangle selection
+ );
+ // empty selection (single column on only left half)
+ try testMouseSelectionIsNull(
+ 3.1, 2, // click
+ 3.0, 4, // drag
+ true, //rectangle selection
+ );
+ // empty selection (single column on only right half)
+ try testMouseSelectionIsNull(
+ 3.9, 2, // click
+ 3.8, 4, // drag
+ true, //rectangle selection
+ );
+ // empty selection (between two columns, not crossing threshold)
+ try testMouseSelectionIsNull(
+ 4.0, 2, // click
+ 3.9, 4, // drag
+ true, //rectangle selection
+ );
+
+ // -- Wrapping
+ // LTR, do not wrap
+ try testMouseSelection(
+ 9.9, 2, // click
+ 0.0, 4, // drag
+ 9, 2, // expected start
+ 0, 4, // expected end
+ true, //rectangle selection
+ );
+ // RTL, do not wrap
+ try testMouseSelection(
+ 0.0, 4, // click
+ 9.9, 2, // drag
+ 0, 4, // expected start
+ 9, 2, // expected end
+ true, //rectangle selection
+ );
+}
diff --git a/src/apprt/action.zig b/src/apprt/action.zig
index 8a23bc1a4..b4c5164c2 100644
--- a/src/apprt/action.zig
+++ b/src/apprt/action.zig
@@ -165,6 +165,9 @@ pub const Action = union(Key) {
/// Control whether the inspector is shown or hidden.
inspector: Inspector,
+ /// Show the GTK inspector.
+ show_gtk_inspector,
+
/// The inspector for the given target has changes and should be
/// rendered at the next opportunity.
render_inspector,
@@ -255,6 +258,13 @@ pub const Action = union(Key) {
/// it needs to ring the bell. This is usually a sound or visual effect.
ring_bell,
+ /// Undo the last action. See the "undo" keybinding for more
+ /// details on what can and cannot be undone.
+ undo,
+
+ /// Redo the last undone action.
+ redo,
+
check_for_updates,
/// Sync with: ghostty_action_tag_e
@@ -284,6 +294,7 @@ pub const Action = union(Key) {
initial_size,
cell_size,
inspector,
+ show_gtk_inspector,
render_inspector,
desktop_notification,
set_title,
@@ -303,6 +314,8 @@ pub const Action = union(Key) {
config_change,
close_window,
ring_bell,
+ undo,
+ redo,
check_for_updates,
};
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 7bc84bcad..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,
@@ -376,6 +379,14 @@ pub const PlatformTag = enum(c_int) {
ios = 2,
};
+pub const EnvVar = extern struct {
+ /// The name of the environment variable.
+ key: [*:0]const u8,
+
+ /// The value of the environment variable.
+ value: [*:0]const u8,
+};
+
pub const Surface = struct {
app: *App,
platform: Platform,
@@ -407,7 +418,7 @@ pub const Surface = struct {
font_size: f32 = 0,
/// The working directory to load into.
- working_directory: [*:0]const u8 = "",
+ working_directory: ?[*:0]const u8 = null,
/// The command to run in the new surface. If this is set then
/// the "wait-after-command" option is also automatically set to true,
@@ -417,13 +428,20 @@ pub const Surface = struct {
/// despite Ghostty allowing directly executed commands via config.
/// This is a legacy thing and we should probably change it in the
/// future once we have a concrete use case.
- command: [*:0]const u8 = "",
+ command: ?[*:0]const u8 = null,
+
+ /// Extra environment variables to set for the surface.
+ env_vars: ?[*]EnvVar = null,
+ env_var_count: usize = 0,
+
+ /// Input to send to the command after it is started.
+ initial_input: ?[*:0]const u8 = null,
};
pub fn init(self: *Surface, app: *App, opts: Options) !void {
self.* = .{
.app = app,
- .platform = try Platform.init(opts.platform_tag, opts.platform),
+ .platform = try .init(opts.platform_tag, opts.platform),
.userdata = opts.userdata,
.core_surface = undefined,
.content_scale = .{
@@ -443,41 +461,72 @@ pub const Surface = struct {
defer config.deinit();
// If we have a working directory from the options then we set it.
- const wd = std.mem.sliceTo(opts.working_directory, 0);
- if (wd.len > 0) wd: {
- var dir = std.fs.openDirAbsolute(wd, .{}) catch |err| {
- log.warn(
- "error opening requested working directory dir={s} err={}",
- .{ wd, err },
- );
- break :wd;
- };
- defer dir.close();
+ if (opts.working_directory) |c_wd| {
+ const wd = std.mem.sliceTo(c_wd, 0);
+ if (wd.len > 0) wd: {
+ var dir = std.fs.openDirAbsolute(wd, .{}) catch |err| {
+ log.warn(
+ "error opening requested working directory dir={s} err={}",
+ .{ wd, err },
+ );
+ break :wd;
+ };
+ defer dir.close();
+
+ const stat = dir.stat() catch |err| {
+ log.warn(
+ "failed to stat requested working directory dir={s} err={}",
+ .{ wd, err },
+ );
+ break :wd;
+ };
- const stat = dir.stat() catch |err| {
- log.warn(
- "failed to stat requested working directory dir={s} err={}",
- .{ wd, err },
- );
- break :wd;
- };
+ if (stat.kind != .directory) {
+ log.warn(
+ "requested working directory is not a directory dir={s}",
+ .{wd},
+ );
+ break :wd;
+ }
- if (stat.kind != .directory) {
- log.warn(
- "requested working directory is not a directory dir={s}",
- .{wd},
- );
- break :wd;
+ config.@"working-directory" = wd;
}
-
- config.@"working-directory" = wd;
}
// If we have a command from the options then we set it.
- const cmd = std.mem.sliceTo(opts.command, 0);
- if (cmd.len > 0) {
- config.command = .{ .shell = cmd };
- config.@"wait-after-command" = true;
+ if (opts.command) |c_command| {
+ const cmd = std.mem.sliceTo(c_command, 0);
+ if (cmd.len > 0) {
+ config.command = .{ .shell = cmd };
+ config.@"wait-after-command" = true;
+ }
+ }
+
+ // Apply any environment variables that were requested.
+ if (opts.env_var_count > 0) {
+ const alloc = config.arenaAlloc();
+ for (opts.env_vars.?[0..opts.env_var_count]) |env_var| {
+ const key = std.mem.sliceTo(env_var.key, 0);
+ const value = std.mem.sliceTo(env_var.value, 0);
+ try config.env.map.put(
+ alloc,
+ try alloc.dupeZ(u8, key),
+ try alloc.dupeZ(u8, value),
+ );
+ }
+ }
+
+ // If we have an initial input then we set it.
+ if (opts.initial_input) |c_input| {
+ const alloc = config.arenaAlloc();
+ config.input.list.clearRetainingCapacity();
+ try config.input.list.append(
+ alloc,
+ .{ .raw = try alloc.dupeZ(u8, std.mem.sliceTo(
+ c_input,
+ 0,
+ )) },
+ );
}
// Initialize our surface right away. We're given a view that is
@@ -522,7 +571,7 @@ pub const Surface = struct {
const alloc = self.app.core_app.alloc;
const inspector = try alloc.create(Inspector);
errdefer alloc.destroy(inspector);
- inspector.* = try Inspector.init(self);
+ inspector.* = try .init(self);
self.inspector = inspector;
return inspector;
}
@@ -842,7 +891,10 @@ pub const Surface = struct {
// our translation settings for Ghostty. If we aren't from
// the desktop then we didn't set our LANGUAGE var so we
// don't need to remove it.
- if (internal_os.launchedFromDesktop()) env.remove("LANGUAGE");
+ switch (self.app.config.@"launched-from".?) {
+ .desktop => env.remove("LANGUAGE"),
+ .dbus, .systemd, .cli => {},
+ }
}
return env;
@@ -1135,13 +1187,6 @@ pub const CAPI = struct {
}
};
- const Selection = extern struct {
- tl_x_px: f64,
- tl_y_px: f64,
- offset_start: u32,
- offset_len: u32,
- };
-
const SurfaceSize = extern struct {
columns: u16,
rows: u16,
@@ -1151,6 +1196,104 @@ pub const CAPI = struct {
cell_height_px: u32,
};
+ // ghostty_text_s
+ const Text = extern struct {
+ tl_px_x: f64,
+ tl_px_y: f64,
+ offset_start: u32,
+ offset_len: u32,
+ text: ?[*:0]const u8,
+ text_len: usize,
+
+ pub fn deinit(self: *Text) void {
+ if (self.text) |ptr| {
+ global.alloc.free(ptr[0..self.text_len :0]);
+ }
+ }
+ };
+
+ // ghostty_point_s
+ const Point = extern struct {
+ tag: Tag,
+ coord_tag: CoordTag,
+ x: u32,
+ y: u32,
+
+ const Tag = enum(c_int) {
+ active = 0,
+ viewport = 1,
+ screen = 2,
+ history = 3,
+ };
+
+ const CoordTag = enum(c_int) {
+ exact = 0,
+ top_left = 1,
+ bottom_right = 2,
+ };
+
+ fn pin(
+ self: Point,
+ screen: *const terminal.Screen,
+ ) ?terminal.Pin {
+ // The core point tag.
+ const tag: terminal.point.Tag = switch (self.tag) {
+ inline else => |tag| @field(
+ terminal.point.Tag,
+ @tagName(tag),
+ ),
+ };
+
+ // Clamp our point to the screen bounds.
+ const clamped_x = @min(self.x, screen.pages.cols -| 1);
+ const clamped_y = @min(self.y, screen.pages.rows -| 1);
+
+ return switch (self.coord_tag) {
+ // Exact coordinates require a specific pin.
+ .exact => exact: {
+ const pt_x = std.math.cast(
+ terminal.size.CellCountInt,
+ clamped_x,
+ ) orelse std.math.maxInt(terminal.size.CellCountInt);
+
+ const pt: terminal.Point = switch (tag) {
+ inline else => |v| @unionInit(
+ terminal.Point,
+ @tagName(v),
+ .{ .x = pt_x, .y = clamped_y },
+ ),
+ };
+
+ break :exact screen.pages.pin(pt) orelse null;
+ },
+
+ .top_left => screen.pages.getTopLeft(tag),
+
+ .bottom_right => screen.pages.getBottomRight(tag),
+ };
+ }
+ };
+
+ // ghostty_selection_s
+ const Selection = extern struct {
+ tl: Point,
+ br: Point,
+ rectangle: bool,
+
+ fn core(
+ self: Selection,
+ screen: *const terminal.Screen,
+ ) ?terminal.Selection {
+ return .{
+ .bounds = .{ .untracked = .{
+ .start = self.tl.pin(screen) orelse return null,
+ .end = self.br.pin(screen) orelse return null,
+ } },
+ .rectangle = self.rectangle,
+ };
+ }
+ };
+
// Reference the conditional exports based on target platform
// so they're included in the C API.
comptime {
@@ -1174,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 App.init(core_app, config, opts.*);
+ try app.init(core_app, config, opts.*);
errdefer app.terminate();
return app;
@@ -1356,28 +1499,90 @@ pub const CAPI = struct {
return surface.core_surface.needsConfirmQuit();
}
+ /// Returns true if the surface process has exited.
+ export fn ghostty_surface_process_exited(surface: *Surface) bool {
+ return surface.core_surface.child_exited;
+ }
+
/// Returns true if the surface has a selection.
export fn ghostty_surface_has_selection(surface: *Surface) bool {
return surface.core_surface.hasSelection();
}
- /// Copies the surface selection text into the provided buffer and
- /// returns the copied size. If the buffer is too small, there is no
- /// selection, or there is an error, then 0 is returned.
- export fn ghostty_surface_selection(surface: *Surface, buf: [*]u8, cap: usize) usize {
- const selection_ = surface.core_surface.selectionString(global.alloc) catch |err| {
- log.warn("error getting selection err={}", .{err});
- return 0;
+ /// Same as ghostty_surface_read_text but reads from the user selection,
+ /// if any.
+ export fn ghostty_surface_read_selection(
+ surface: *Surface,
+ result: *Text,
+ ) bool {
+ const core_surface = &surface.core_surface;
+ core_surface.renderer_state.mutex.lock();
+ defer core_surface.renderer_state.mutex.unlock();
+
+ // If we don't have a selection, do nothing.
+ const core_sel = core_surface.io.terminal.screen.selection orelse return false;
+
+ // Read the text from the selection.
+ return readTextLocked(surface, core_sel, result);
+ }
+
+ /// Read some arbitrary text from the surface.
+ ///
+ /// This is an expensive operation so it shouldn't be called too
+ /// often. We recommend that callers cache the result and throttle
+ /// calls to this function.
+ export fn ghostty_surface_read_text(
+ surface: *Surface,
+ sel: Selection,
+ result: *Text,
+ ) bool {
+ surface.core_surface.renderer_state.mutex.lock();
+ defer surface.core_surface.renderer_state.mutex.unlock();
+
+ const core_sel = sel.core(
+ &surface.core_surface.renderer_state.terminal.screen,
+ ) orelse return false;
+
+ return readTextLocked(surface, core_sel, result);
+ }
+
+ fn readTextLocked(
+ surface: *Surface,
+ core_sel: terminal.Selection,
+ result: *Text,
+ ) bool {
+ const core_surface = &surface.core_surface;
+
+ // Get our text directly from the core surface.
+ const text = core_surface.dumpTextLocked(
+ global.alloc,
+ core_sel,
+ ) catch |err| {
+ log.warn("error reading text err={}", .{err});
+ return false;
+ };
+
+ const vp: CoreSurface.Text.Viewport = text.viewport orelse .{
+ .tl_px_x = -1,
+ .tl_px_y = -1,
+ .offset_start = 0,
+ .offset_len = 0,
};
- const selection = selection_ orelse return 0;
- defer global.alloc.free(selection);
- // If the buffer is too small, return no selection.
- if (selection.len > cap) return 0;
+ result.* = .{
+ .tl_px_x = vp.tl_px_x,
+ .tl_px_y = vp.tl_px_y,
+ .offset_start = vp.offset_start,
+ .offset_len = vp.offset_len,
+ .text = text.text.ptr,
+ .text_len = text.text.len,
+ };
- // Copy into the buffer and return the length
- @memcpy(buf[0..selection.len], selection);
- return selection.len;
+ return true;
+ }
+
+ export fn ghostty_surface_free_text(ptr: *Text) void {
+ ptr.deinit();
}
/// Tell the surface that it needs to schedule a render
@@ -1681,12 +1886,10 @@ pub const CAPI = struct {
return false;
};
- _ = ptr.core_surface.performBindingAction(action) catch |err| {
+ return ptr.core_surface.performBindingAction(action) catch |err| {
log.err("error performing binding action action={} err={}", .{ action, err });
return false;
};
-
- return true;
}
/// Complete a clipboard read request started via the read callback.
@@ -1880,21 +2083,12 @@ pub const CAPI = struct {
/// This does not modify the selection active on the surface (if any).
export fn ghostty_surface_quicklook_word(
ptr: *Surface,
- buf: [*]u8,
- cap: usize,
- info: *Selection,
- ) usize {
+ result: *Text,
+ ) bool {
const surface = &ptr.core_surface;
surface.renderer_state.mutex.lock();
defer surface.renderer_state.mutex.unlock();
- // To make everything in this function easier, we modify the
- // selection to be the word under the cursor and call normal APIs.
- // We restore the old selection so it isn't ever changed. Since we hold
- // the renderer mutex it'll never show up in a frame.
- const prev = surface.io.terminal.screen.selection;
- defer surface.io.terminal.screen.selection = prev;
-
// Get our word selection
const sel = sel: {
const screen = &surface.renderer_state.terminal.screen;
@@ -1907,49 +2101,17 @@ pub const CAPI = struct {
},
}) orelse {
if (comptime std.debug.runtime_safety) unreachable;
- return 0;
+ return false;
};
- break :sel surface.io.terminal.screen.selectWord(pin) orelse return 0;
+ break :sel surface.io.terminal.screen.selectWord(pin) orelse return false;
};
- // Set the selection
- surface.io.terminal.screen.selection = sel;
-
- // No we call normal functions. These require that the lock
- // is unlocked. This may cause a frame flicker with the fake
- // selection but I think the lack of new complexity is worth it
- // for now.
- {
- surface.renderer_state.mutex.unlock();
- defer surface.renderer_state.mutex.lock();
- const len = ghostty_surface_selection(ptr, buf, cap);
- if (!ghostty_surface_selection_info(ptr, info)) return 0;
- return len;
- }
- }
-
- /// This returns the selection metadata for the current selection.
- /// This will return false if there is no selection or the
- /// selection is not fully contained in the viewport (since the
- /// metadata is all about that).
- export fn ghostty_surface_selection_info(
- ptr: *Surface,
- info: *Selection,
- ) bool {
- const sel = ptr.core_surface.selectionInfo() orelse
- return false;
-
- info.* = .{
- .tl_x_px = sel.tl_x_px,
- .tl_y_px = sel.tl_y_px,
- .offset_start = sel.offset_start,
- .offset_len = sel.offset_len,
- };
- return true;
+ // Read the selection
+ return readTextLocked(ptr, sel, result);
}
export fn ghostty_inspector_metal_init(ptr: *Inspector, device: objc.c.id) bool {
- return ptr.initMetal(objc.Object.fromId(device));
+ return ptr.initMetal(.fromId(device));
}
export fn ghostty_inspector_metal_render(
@@ -1958,8 +2120,8 @@ pub const CAPI = struct {
descriptor: objc.c.id,
) void {
return ptr.renderMetal(
- objc.Object.fromId(command_buffer),
- objc.Object.fromId(descriptor),
+ .fromId(command_buffer),
+ .fromId(descriptor),
) catch |err| {
log.err("error rendering inspector err={}", .{err});
return;
diff --git a/src/apprt/glfw.zig b/src/apprt/glfw.zig
index 221d5344a..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,
@@ -250,6 +252,9 @@ pub const App = struct {
.reset_window_size,
.ring_bell,
.check_for_updates,
+ .undo,
+ .redo,
+ .show_gtk_inspector,
=> {
log.info("unimplemented action={}", .{action});
return false;
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 da828b973..7786f976a 100644
--- a/src/apprt/gtk/App.zig
+++ b/src/apprt/gtk/App.zig
@@ -55,6 +55,11 @@ pub const c = @cImport({
const log = std.log.scoped(.gtk);
+/// This is detected by the Renderer, in which case it sends a `redraw_surface`
+/// message so that we can call `drawFrame` ourselves from the app thread,
+/// because GTK's `GLArea` does not support drawing from a different thread.
+pub const must_draw_from_app_thread = true;
+
pub const Options = struct {};
core_app: *CoreApp,
@@ -105,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
@@ -143,8 +148,8 @@ pub fn init(core_app: *CoreApp, opts: Options) !App {
if (config.@"async-backend" != .auto) {
const result: bool = switch (config.@"async-backend") {
.auto => unreachable,
- .epoll => xev.prefer(.epoll),
- .io_uring => xev.prefer(.io_uring),
+ .epoll => if (comptime xev.dynamic) xev.prefer(.epoll) else false,
+ .io_uring => if (comptime xev.dynamic) xev.prefer(.io_uring) else false,
};
if (result) {
@@ -273,7 +278,10 @@ pub fn init(core_app: *CoreApp, opts: Options) !App {
const single_instance = switch (config.@"gtk-single-instance") {
.true => true,
.false => false,
- .desktop => internal_os.launchedFromDesktop(),
+ .desktop => switch (config.@"launched-from".?) {
+ .desktop, .systemd, .dbus => true,
+ .cli => false,
+ },
};
// Setup the flags for our application.
@@ -288,7 +296,7 @@ pub fn init(core_app: *CoreApp, opts: Options) !App {
// can develop Ghostty in Ghostty.
const app_id: [:0]const u8 = app_id: {
if (config.class) |class| {
- if (isValidAppId(class)) {
+ if (gio.Application.idIsValid(class) != 0) {
break :app_id class;
} else {
log.warn("invalid 'class' in config, ignoring", .{});
@@ -397,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.
@@ -412,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,
@@ -481,6 +493,7 @@ pub fn performAction(
.config_change => self.configChange(target, value.config),
.reload_config => try self.reloadConfig(target, value),
.inspector => self.controlInspector(target, value),
+ .show_gtk_inspector => self.showGTKInspector(),
.desktop_notification => self.showDesktopNotification(target, value),
.set_title => try self.setTitle(target, value),
.pwd => try self.setPwd(target, value),
@@ -511,6 +524,8 @@ pub fn performAction(
.color_change,
.reset_window_size,
.check_for_updates,
+ .undo,
+ .redo,
=> {
log.warn("unimplemented action={}", .{action});
return false;
@@ -687,6 +702,12 @@ fn controlInspector(
surface.controlInspector(mode);
}
+fn showGTKInspector(
+ _: *const App,
+) void {
+ gtk.Window.setInteractiveDebugging(@intFromBool(true));
+}
+
fn toggleMaximize(_: *App, target: apprt.Target) void {
switch (target) {
.app => {},
@@ -1060,6 +1081,7 @@ fn syncActionAccelerators(self: *App) !void {
try self.syncActionAccelerator("app.open-config", .{ .open_config = {} });
try self.syncActionAccelerator("app.reload-config", .{ .reload_config = {} });
try self.syncActionAccelerator("win.toggle-inspector", .{ .inspector = .toggle });
+ try self.syncActionAccelerator("app.show-gtk-inspector", .show_gtk_inspector);
try self.syncActionAccelerator("win.toggle-command-palette", .toggle_command_palette);
try self.syncActionAccelerator("win.close", .{ .close_window = {} });
try self.syncActionAccelerator("win.new-window", .{ .new_window = {} });
@@ -1655,6 +1677,27 @@ fn gtkActionPresentSurface(
);
}
+fn gtkActionShowGTKInspector(
+ _: *gio.SimpleAction,
+ _: ?*glib.Variant,
+ self: *App,
+) callconv(.c) void {
+ self.core_app.performAction(self, .show_gtk_inspector) catch |err| {
+ log.err("error showing GTK inspector err={}", .{err});
+ };
+}
+
+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 {
@@ -1673,7 +1716,10 @@ fn initActions(self: *App) void {
.{ "open-config", gtkActionOpenConfig, null },
.{ "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();
@@ -1688,32 +1734,3 @@ fn initActions(self: *App) void {
action_map.addAction(action.as(gio.Action));
}
}
-
-fn isValidAppId(app_id: [:0]const u8) bool {
- if (app_id.len > 255 or app_id.len == 0) return false;
- if (app_id[0] == '.') return false;
- if (app_id[app_id.len - 1] == '.') return false;
-
- var hasDot = false;
- for (app_id) |char| {
- switch (char) {
- 'a'...'z', 'A'...'Z', '0'...'9', '_', '-' => {},
- '.' => hasDot = true,
- else => return false,
- }
- }
- if (!hasDot) return false;
-
- return true;
-}
-
-test "isValidAppId" {
- try testing.expect(isValidAppId("foo.bar"));
- try testing.expect(isValidAppId("foo.bar.baz"));
- try testing.expect(!isValidAppId("foo"));
- try testing.expect(!isValidAppId("foo.bar?"));
- try testing.expect(!isValidAppId("foo."));
- try testing.expect(!isValidAppId(".foo"));
- try testing.expect(!isValidAppId(""));
- try testing.expect(!isValidAppId("foo" ** 86));
-}
diff --git a/src/apprt/gtk/ClipboardConfirmationWindow.zig b/src/apprt/gtk/ClipboardConfirmationWindow.zig
index f10fc79ac..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,
@@ -69,16 +70,16 @@ fn init(
request: apprt.ClipboardRequest,
is_secure_input: bool,
) !void {
- var builder = switch (DialogType) {
+ var builder: Builder = switch (DialogType) {
adw.AlertDialog => switch (request) {
- .osc_52_read => Builder.init("ccw-osc-52-read", 1, 5),
- .osc_52_write => Builder.init("ccw-osc-52-write", 1, 5),
- .paste => Builder.init("ccw-paste", 1, 5),
+ .osc_52_read => .init("ccw-osc-52-read", 1, 5),
+ .osc_52_write => .init("ccw-osc-52-write", 1, 5),
+ .paste => .init("ccw-paste", 1, 5),
},
adw.MessageDialog => switch (request) {
- .osc_52_read => Builder.init("ccw-osc-52-read", 1, 2),
- .osc_52_write => Builder.init("ccw-osc-52-write", 1, 2),
- .paste => Builder.init("ccw-paste", 1, 2),
+ .osc_52_read => .init("ccw-osc-52-read", 1, 2),
+ .osc_52_write => .init("ccw-osc-52-write", 1, 2),
+ .paste => .init("ccw-paste", 1, 2),
},
else => unreachable,
};
@@ -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 fda2c5ca8..d05f195b3 100644
--- a/src/apprt/gtk/CommandPalette.zig
+++ b/src/apprt/gtk/CommandPalette.zig
@@ -43,6 +43,7 @@ pub fn init(self: *CommandPalette, window: *Window) !void {
_ = Command.getGObjectType();
var builder = Builder.init("command-palette", 1, 5);
+ defer builder.deinit();
self.* = .{
.window = window,
@@ -93,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 {
@@ -103,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 => {},
@@ -120,7 +124,9 @@ pub fn updateConfig(self: *CommandPalette, config: *const configpkg.Config) !voi
command,
config.keybind.set,
);
- self.source.append(cmd.as(gobject.Object));
+ const cmd_ref = cmd.as(gobject.Object);
+ self.source.append(cmd_ref);
+ cmd_ref.unref();
}
}
diff --git a/src/apprt/gtk/ConfigErrorsDialog.zig b/src/apprt/gtk/ConfigErrorsDialog.zig
index ccc5599ad..da70ccce1 100644
--- a/src/apprt/gtk/ConfigErrorsDialog.zig
+++ b/src/apprt/gtk/ConfigErrorsDialog.zig
@@ -32,9 +32,9 @@ pub fn maybePresent(app: *App, window: ?*Window) void {
const config_errors_dialog = config_errors_dialog: {
if (app.config_errors_dialog) |config_errors_dialog| break :config_errors_dialog config_errors_dialog;
- var builder = switch (DialogType) {
- adw.AlertDialog => Builder.init("config-errors-dialog", 1, 5),
- adw.MessageDialog => Builder.init("config-errors-dialog", 1, 2),
+ var builder: Builder = switch (DialogType) {
+ adw.AlertDialog => .init("config-errors-dialog", 1, 5),
+ adw.MessageDialog => .init("config-errors-dialog", 1, 2),
else => unreachable,
};
diff --git a/src/apprt/gtk/GlobalShortcuts.zig b/src/apprt/gtk/GlobalShortcuts.zig
index 7d960d7bf..ac9dbaa8a 100644
--- a/src/apprt/gtk/GlobalShortcuts.zig
+++ b/src/apprt/gtk/GlobalShortcuts.zig
@@ -117,7 +117,9 @@ pub fn refreshSession(self: *GlobalShortcuts, app: *App) !void {
);
}
- try self.request(.create_session);
+ if (self.map.count() > 0) {
+ try self.request(.create_session);
+ }
}
fn shortcutActivated(
diff --git a/src/apprt/gtk/ResizeOverlay.zig b/src/apprt/gtk/ResizeOverlay.zig
index 767cf097d..2ab59624a 100644
--- a/src/apprt/gtk/ResizeOverlay.zig
+++ b/src/apprt/gtk/ResizeOverlay.zig
@@ -50,12 +50,12 @@ first: bool = true,
pub fn init(self: *ResizeOverlay, surface: *Surface, config: *const configpkg.Config) void {
self.* = .{
.surface = surface,
- .config = DerivedConfig.init(config),
+ .config = .init(config),
};
}
pub fn updateConfig(self: *ResizeOverlay, config: *const configpkg.Config) void {
- self.config = DerivedConfig.init(config);
+ self.config = .init(config);
}
/// De-initialize the ResizeOverlay. This removes any pending idlers/timers that
diff --git a/src/apprt/gtk/Split.zig b/src/apprt/gtk/Split.zig
index 9caa9ab56..fb719c3c9 100644
--- a/src/apprt/gtk/Split.zig
+++ b/src/apprt/gtk/Split.zig
@@ -138,7 +138,7 @@ pub fn init(
.container = container,
.top_left = .{ .surface = tl },
.bottom_right = .{ .surface = br },
- .orientation = Orientation.fromDirection(direction),
+ .orientation = .fromDirection(direction),
};
// Replace the previous containers element with our split. This allows a
diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig
index bcb78e087..5c886e663 100644
--- a/src/apprt/gtk/Surface.zig
+++ b/src/apprt/gtk/Surface.zig
@@ -41,10 +41,6 @@ const adw_version = @import("adw_version.zig");
const log = std.log.scoped(.gtk_surface);
-/// This is detected by the OpenGL renderer to move to a single-threaded
-/// draw operation. This basically puts locks around our draw path.
-pub const opengl_single_threaded_draw = true;
-
pub const Options = struct {
/// The parent surface to inherit settings such as font size, working
/// directory, etc. from.
@@ -394,7 +390,10 @@ pub fn init(self: *Surface, app: *App, opts: Options) !void {
// Various other GL properties
gl_area_widget.setCursorFromName("text");
- gl_area.setRequiredVersion(3, 3);
+ gl_area.setRequiredVersion(
+ renderer.OpenGL.MIN_VERSION_MAJOR,
+ renderer.OpenGL.MIN_VERSION_MINOR,
+ );
gl_area.setHasStencilBuffer(0);
gl_area.setHasDepthBuffer(0);
gl_area.setUseEs(0);
@@ -683,12 +682,13 @@ pub fn init(self: *Surface, app: *App, opts: Options) !void {
fn realize(self: *Surface) !void {
// If this surface has already been realized, then we don't need to
- // reinitialize. This can happen if a surface is moved from one GDK surface
- // to another (i.e. a tab is pulled out into a window).
+ // reinitialize. This can happen if a surface is moved from one GDK
+ // surface to another (i.e. a tab is pulled out into a window).
if (self.realized) {
// If we have no OpenGL state though, we do need to reinitialize.
- // We allow the renderer to figure that out
- try self.core_surface.renderer.displayRealize();
+ // We allow the renderer to figure that out, and then queue a draw.
+ try self.core_surface.renderer.displayRealized();
+ self.redraw();
return;
}
@@ -746,7 +746,21 @@ pub fn deinit(self: *Surface) void {
self.core_surface.deinit();
self.core_surface = undefined;
- if (self.cgroup_path) |path| self.app.core_app.alloc.free(path);
+ // Remove the cgroup if we have one. We do this after deiniting the core
+ // surface to ensure all processes have exited.
+ if (self.cgroup_path) |path| {
+ internal_os.cgroup.remove(path) catch |err| {
+ // We don't want this to be fatal in any way so we just log
+ // and continue. A dangling empty cgroup is not a big deal
+ // and this should be rare.
+ log.warn(
+ "failed to remove cgroup for surface path={s} err={}",
+ .{ path, err },
+ );
+ };
+
+ self.app.core_app.alloc.free(path);
+ }
// Free all our GTK stuff
//
@@ -780,7 +794,7 @@ pub fn primaryWidget(self: *Surface) *gtk.Widget {
}
fn render(self: *Surface) !void {
- try self.core_surface.renderer.drawFrame(self);
+ try self.core_surface.renderer.drawFrame(true);
}
/// Called by core surface to get the cgroup.
@@ -1191,7 +1205,7 @@ pub fn mouseOverLink(self: *Surface, uri_: ?[]const u8) void {
return;
}
- self.url_widget = URLWidget.init(self.overlay, uriZ);
+ self.url_widget = .init(self.overlay, uriZ);
}
pub fn supportsClipboard(
@@ -1563,7 +1577,7 @@ fn gtkMouseMotion(
const scaled = self.scaledCoordinates(x, y);
const pos: apprt.CursorPos = .{
- .x = @floatCast(@max(0, scaled.x)),
+ .x = @floatCast(scaled.x),
.y = @floatCast(scaled.y),
};
@@ -2311,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")) |_| {
@@ -2440,6 +2463,13 @@ pub fn ringBell(self: *Surface) !void {
media_stream.play();
}
+ if (features.attention) {
+ // Request user attention
+ window.winproto.setUrgent(true) catch |err| {
+ log.err("failed to request user attention={}", .{err});
+ };
+ }
+
// Mark tab as needing attention
if (self.container.tab()) |tab| tab: {
const page = window.notebook.getTabPage(tab) orelse break :tab;
diff --git a/src/apprt/gtk/TabView.zig b/src/apprt/gtk/TabView.zig
index 29a069a6d..8a4145b5f 100644
--- a/src/apprt/gtk/TabView.zig
+++ b/src/apprt/gtk/TabView.zig
@@ -7,6 +7,7 @@ const std = @import("std");
const gtk = @import("gtk");
const adw = @import("adw");
const gobject = @import("gobject");
+const glib = @import("glib");
const Window = @import("Window.zig");
const Tab = @import("Tab.zig");
@@ -243,7 +244,14 @@ fn adwClosePage(
const child = page.getChild().as(gobject.Object);
const tab: *Tab = @ptrCast(@alignCast(child.getData(Tab.GHOSTTY_TAB) orelse return 0));
self.tab_view.closePageFinish(page, @intFromBool(self.forcing_close));
- if (!self.forcing_close) tab.closeWithConfirmation();
+ if (!self.forcing_close) {
+ // We cannot trigger a close directly in here as the page will stay
+ // alive until this handler returns, breaking the assumption where
+ // no pages means they are all destroyed.
+ //
+ // Schedule the close request to happen in the next event cycle.
+ _ = glib.idleAddOnce(glibIdleOnceCloseTab, tab);
+ }
return 1;
}
@@ -269,3 +277,8 @@ fn adwSelectPage(_: *adw.TabView, _: *gobject.ParamSpec, self: *TabView) callcon
const title = page.getTitle();
self.window.setTitle(std.mem.span(title));
}
+
+fn glibIdleOnceCloseTab(data: ?*anyopaque) callconv(.c) void {
+ const tab: *Tab = @ptrCast(@alignCast(data orelse return));
+ tab.closeWithConfirmation();
+}
diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig
index 4a5926a97..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,10 +89,12 @@ 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,
quick_terminal_autohide: bool,
+ quick_terminal_keyboard_interactivity: configpkg.Config.QuickTerminalKeyboardInteractivity,
maximize: bool,
fullscreen: bool,
@@ -105,10 +110,12 @@ 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",
.quick_terminal_autohide = config.@"quick-terminal-autohide",
+ .quick_terminal_keyboard_interactivity = config.@"quick-terminal-keyboard-interactivity",
.maximize = config.maximize,
.fullscreen = config.fullscreen,
@@ -136,9 +143,10 @@ pub fn init(self: *Window, app: *App) !void {
self.* = .{
.app = app,
.last_config = @intFromPtr(&app.config),
- .config = DerivedConfig.init(&app.config),
+ .config = .init(&app.config),
.window = undefined,
.headerbar = undefined,
+ .tab_bar = undefined,
.tab_overview = null,
.notebook = undefined,
.titlebar_menu = undefined,
@@ -148,7 +156,7 @@ pub fn init(self: *Window, app: *App) !void {
};
// Create the window
- self.window = adw.ApplicationWindow.new(app.app.as(gtk.Application));
+ self.window = .new(app.app.as(gtk.Application));
const gtk_window = self.window.as(gtk.Window);
const gtk_widget = self.window.as(gtk.Widget);
errdefer gtk_window.destroy();
@@ -223,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");
@@ -236,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");
@@ -333,7 +341,7 @@ pub fn init(self: *Window, app: *App) !void {
}
// Setup our toast overlay if we have one
- self.toast_overlay = adw.ToastOverlay.new();
+ self.toast_overlay = .new();
self.toast_overlay.setChild(self.notebook.asWidget());
box.append(self.toast_overlay.as(gtk.Widget));
@@ -383,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));
@@ -412,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.
@@ -463,7 +461,7 @@ pub fn updateConfig(
if (self.last_config == this_config) return;
self.last_config = this_config;
- self.config = DerivedConfig.init(config);
+ self.config = .init(config);
// We always resync our appearance whenever the config changes.
try self.syncAppearance();
@@ -553,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});
};
@@ -814,11 +822,15 @@ fn gtkWindowNotifyIsActive(
_: *gobject.ParamSpec,
self: *Window,
) callconv(.c) void {
- if (!self.isQuickTerminal()) return;
+ self.winproto.setUrgent(false) catch |err| {
+ log.err("failed to unrequest user attention={}", .{err});
+ };
- // Hide when we're unfocused
- if (self.config.quick_terminal_autohide and self.window.as(gtk.Window).isActive() == 0) {
- self.toggleVisibility();
+ if (self.isQuickTerminal()) {
+ // Hide when we're unfocused
+ if (self.config.quick_terminal_autohide and self.window.as(gtk.Window).isActive() == 0) {
+ self.toggleVisibility();
+ }
}
}
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/inspector.zig b/src/apprt/gtk/inspector.zig
index e3e61e258..3adeb9711 100644
--- a/src/apprt/gtk/inspector.zig
+++ b/src/apprt/gtk/inspector.zig
@@ -138,7 +138,7 @@ const Window = struct {
};
// Create the window
- self.window = gtk.ApplicationWindow.new(inspector.surface.app.app.as(gtk.Application));
+ self.window = .new(inspector.surface.app.app.as(gtk.Application));
errdefer self.window.as(gtk.Window).destroy();
self.window.as(gtk.Window).setTitle(i18n._("Ghostty: Terminal Inspector"));
diff --git a/src/apprt/gtk/key.zig b/src/apprt/gtk/key.zig
index 3dcfaed98..fc3296366 100644
--- a/src/apprt/gtk/key.zig
+++ b/src/apprt/gtk/key.zig
@@ -388,6 +388,10 @@ const keymap: []const RawEntry = &.{
.{ gdk.KEY_KP_Delete, .numpad_delete },
.{ gdk.KEY_KP_Begin, .numpad_begin },
+ .{ gdk.KEY_Copy, .copy },
+ .{ gdk.KEY_Cut, .cut },
+ .{ gdk.KEY_Paste, .paste },
+
.{ gdk.KEY_Shift_L, .shift_left },
.{ gdk.KEY_Control_L, .control_left },
.{ gdk.KEY_Alt_L, .alt_left },
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;
+ 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;
- styles [
- "clipboard-content-view",
- ]
+ 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;
+ [overlay]
+ Button reveal_button {
+ visible: false;
+ halign: end;
+ valign: start;
+ margin-end: 12;
+ margin-top: 12;
- Image {
- icon-name: "view-reveal-symbolic";
+ Image {
+ icon-name: "view-reveal-symbolic";
+ }
}
- }
- [overlay]
- Button hide_button {
- visible: false;
- halign: end;
- valign: start;
- margin-end: 12;
- margin-top: 12;
+ [overlay]
+ Button hide_button {
+ visible: false;
+ halign: end;
+ valign: start;
+ margin-end: 12;
+ margin-top: 12;
- styles [
- "opaque",
- ]
+ styles [
+ "opaque",
+ ]
- Image {
- icon-name: "view-conceal-symbolic";
+ 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;
- styles [
- "clipboard-content-view",
- ]
+ 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;
+ [overlay]
+ Button reveal_button {
+ visible: false;
+ halign: end;
+ valign: start;
+ margin-end: 12;
+ margin-top: 12;
- Image {
- icon-name: "view-reveal-symbolic";
+ Image {
+ icon-name: "view-reveal-symbolic";
+ }
}
- }
- [overlay]
- Button hide_button {
- visible: false;
- halign: end;
- valign: start;
- margin-end: 12;
- margin-top: 12;
+ [overlay]
+ Button hide_button {
+ visible: false;
+ halign: end;
+ valign: start;
+ margin-end: 12;
+ margin-top: 12;
- styles [
- "opaque",
- ]
-
- Image {
- icon-name: "view-conceal-symbolic";
+ styles [
+ "opaque",
+ ]
}
}
+
+ 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.zig b/src/apprt/gtk/winproto.zig
index ff83e6851..2dbe5a7a0 100644
--- a/src/apprt/gtk/winproto.zig
+++ b/src/apprt/gtk/winproto.zig
@@ -146,4 +146,10 @@ pub const Window = union(Protocol) {
inline else => |*v| try v.addSubprocessEnv(env),
}
}
+
+ pub fn setUrgent(self: *Window, urgent: bool) !void {
+ switch (self.*) {
+ inline else => |*v| try v.setUrgent(urgent),
+ }
+ }
};
diff --git a/src/apprt/gtk/winproto/noop.zig b/src/apprt/gtk/winproto/noop.zig
index 5cb5887c9..fb732b756 100644
--- a/src/apprt/gtk/winproto/noop.zig
+++ b/src/apprt/gtk/winproto/noop.zig
@@ -70,4 +70,6 @@ pub const Window = struct {
}
pub fn addSubprocessEnv(_: *Window, _: *std.process.EnvMap) !void {}
+
+ pub fn setUrgent(_: *Window, _: bool) !void {}
};
diff --git a/src/apprt/gtk/winproto/wayland.zig b/src/apprt/gtk/winproto/wayland.zig
index 5f5feca6e..ae3c871f2 100644
--- a/src/apprt/gtk/winproto/wayland.zig
+++ b/src/apprt/gtk/winproto/wayland.zig
@@ -6,8 +6,8 @@ const build_options = @import("build_options");
const gdk = @import("gdk");
const gdk_wayland = @import("gdk_wayland");
const gobject = @import("gobject");
-const gtk4_layer_shell = @import("gtk4-layer-shell");
const gtk = @import("gtk");
+const layer_shell = @import("gtk4-layer-shell");
const wayland = @import("wayland");
const Config = @import("../../../config.zig").Config;
@@ -16,6 +16,7 @@ const ApprtWindow = @import("../Window.zig");
const wl = wayland.client.wl;
const org = wayland.client.org;
+const xdg = wayland.client.xdg;
const log = std.log.scoped(.winproto_wayland);
@@ -34,6 +35,21 @@ pub const App = struct {
kde_slide_manager: ?*org.KdeKwinSlideManager = null,
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(
@@ -45,16 +61,11 @@ pub const App = struct {
_ = config;
_ = app_id;
- // Check if we're actually on Wayland
- if (gobject.typeCheckInstanceIsA(
- gdk_display.as(gobject.TypeInstance),
- gdk_wayland.WaylandDisplay.getGObjectType(),
- ) == 0) return null;
-
const gdk_wayland_display = gobject.ext.cast(
gdk_wayland.WaylandDisplay,
gdk_display,
- ) orelse return error.NoWaylandDisplay;
+ ) orelse return null;
+
const display: *wl.Display = @ptrCast(@alignCast(
gdk_wayland_display.getWlDisplay() orelse return error.NoWaylandDisplay,
));
@@ -73,9 +84,9 @@ pub const App = struct {
registry.setListener(*Context, registryListener, context);
if (display.roundtrip() != .SUCCESS) return error.RoundtripFailed;
- if (context.kde_decoration_manager != null) {
- // FIXME: Roundtrip again because we have to wait for the decoration
- // manager to respond with the preferred default mode. Ew.
+ // Do another round-trip to get the default decoration mode
+ if (context.kde_decoration_manager) |deco_manager| {
+ deco_manager.setListener(*Context, decoManagerListener, context);
if (display.roundtrip() != .SUCCESS) return error.RoundtripFailed;
}
@@ -97,20 +108,45 @@ pub const App = struct {
return null;
}
- pub fn supportsQuickTerminal(_: App) bool {
- if (!gtk4_layer_shell.isSupported()) {
+ 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;
}
pub fn initQuickTerminal(_: *App, apprt_window: *ApprtWindow) !void {
const window = apprt_window.window.as(gtk.Window);
- gtk4_layer_shell.initForWindow(window);
- gtk4_layer_shell.setLayer(window, .top);
- gtk4_layer_shell.setKeyboardMode(window, .on_demand);
+ layer_shell.initForWindow(window);
+ layer_shell.setLayer(window, .top);
+ 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(
@@ -118,71 +154,54 @@ pub const App = struct {
event: wl.Registry.Event,
context: *Context,
) void {
- switch (event) {
- // https://wayland.app/protocols/wayland#wl_registry:event:global
- .global => |global| {
- log.debug("wl_registry.global: interface={s}", .{global.interface});
-
- if (registryBind(
- org.KdeKwinBlurManager,
- registry,
- global,
- )) |blur_manager| {
- context.kde_blur_manager = blur_manager;
- return;
- }
+ const ctx_fields = @typeInfo(Context).@"struct".fields;
- if (registryBind(
- org.KdeKwinServerDecorationManager,
- registry,
- global,
- )) |deco_manager| {
- context.kde_decoration_manager = deco_manager;
- deco_manager.setListener(*Context, decoManagerListener, context);
- return;
+ 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;
+
+ if (std.mem.orderZ(
+ u8,
+ v.interface,
+ T.interface.name,
+ ) != .eq) break :global;
+
+ @field(context, field.name) = registry.bind(
+ v.name,
+ T,
+ T.generated_version,
+ ) catch |err| {
+ log.warn(
+ "error binding interface {s} error={}",
+ .{ v.interface, err },
+ );
+ return;
+ };
}
+ },
- if (registryBind(
- org.KdeKwinSlideManager,
- registry,
- global,
- )) |slide_manager| {
- context.kde_slide_manager = slide_manager;
- 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: {
+ 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;
+ }
}
},
-
- // We don't handle removal events
- .global_remove => {},
}
}
- /// Bind a Wayland interface to a global object. Returns non-null
- /// if the binding was successful, otherwise null.
- ///
- /// The type T is the Wayland interface type that we're requesting.
- /// This function will verify that the global object is the correct
- /// interface and version before binding.
- fn registryBind(
- comptime T: type,
- registry: *wl.Registry,
- global: anytype,
- ) ?*T {
- if (std.mem.orderZ(
- u8,
- global.interface,
- T.interface.name,
- ) != .eq) return null;
-
- return registry.bind(global.name, T, T.generated_version) catch |err| {
- log.warn("error binding interface {s} error={}", .{
- global.interface,
- err,
- });
- return null;
- };
- }
-
fn decoManagerListener(
_: *org.KdeKwinServerDecorationManager,
event: org.KdeKwinServerDecorationManager.Event,
@@ -207,15 +226,19 @@ pub const Window = struct {
app_context: *App.Context,
/// A token that, when present, indicates that the window is blurred.
- blur_token: ?*org.KdeKwinBlur,
+ blur_token: ?*org.KdeKwinBlur = null,
/// Object that controls the decoration mode (client/server/auto)
/// of the window.
- decoration: ?*org.KdeKwinServerDecoration,
+ decoration: ?*org.KdeKwinServerDecoration = null,
/// Object that controls the slide-in/slide-out animations of the
/// quick terminal. Always null for windows other than the quick terminal.
- slide: ?*org.KdeKwinSlide,
+ slide: ?*org.KdeKwinSlide = null,
+
+ /// Object that, when present, denotes that the window is currently
+ /// requesting attention from the user.
+ activation_token: ?*xdg.ActivationTokenV1 = null,
pub fn init(
alloc: Allocator,
@@ -268,9 +291,7 @@ pub const Window = struct {
.apprt_window = apprt_window,
.surface = wl_surface,
.app_context = app.context,
- .blur_token = null,
.decoration = deco,
- .slide = null,
};
}
@@ -315,6 +336,21 @@ pub const Window = struct {
_ = env;
}
+ pub fn setUrgent(self: *Window, urgent: bool) !void {
+ const activation = self.app_context.xdg_activation orelse return;
+
+ // If there already is a token, destroy and unset it
+ if (self.activation_token) |token| token.destroy();
+
+ self.activation_token = if (urgent) token: {
+ const token = try activation.getActivationToken();
+ token.setSurface(self.surface);
+ token.setListener(*Window, onActivationTokenEvent, self);
+ token.commit();
+ break :token token;
+ } else null;
+ }
+
/// Update the blur state of the window.
fn syncBlur(self: *Window) !void {
const manager = self.app_context.kde_blur_manager orelse return;
@@ -356,9 +392,24 @@ pub const Window = struct {
fn syncQuickTerminal(self: *Window) !void {
const window = self.apprt_window.window.as(gtk.Window);
- const position = self.apprt_window.config.quick_terminal_position;
+ const config = &self.apprt_window.config;
+
+ layer_shell.setKeyboardMode(
+ window,
+ switch (config.quick_terminal_keyboard_interactivity) {
+ .none => .none,
+ .@"on-demand" => on_demand: {
+ if (layer_shell.getProtocolVersion() < 4) {
+ log.warn("your compositor does not support on-demand keyboard access; falling back to exclusive access", .{});
+ break :on_demand .exclusive;
+ }
+ break :on_demand .on_demand;
+ },
+ .exclusive => .exclusive,
+ },
+ );
- const anchored_edge: ?gtk4_layer_shell.ShellEdge = switch (position) {
+ const anchored_edge: ?layer_shell.ShellEdge = switch (config.quick_terminal_position) {
.left => .left,
.right => .right,
.top => .top,
@@ -366,43 +417,41 @@ pub const Window = struct {
.center => null,
};
- for (std.meta.tags(gtk4_layer_shell.ShellEdge)) |edge| {
+ for (std.meta.tags(layer_shell.ShellEdge)) |edge| {
if (anchored_edge) |anchored| {
if (edge == anchored) {
- gtk4_layer_shell.setMargin(window, edge, 0);
- gtk4_layer_shell.setAnchor(window, edge, true);
+ layer_shell.setMargin(window, edge, 0);
+ layer_shell.setAnchor(window, edge, true);
continue;
}
}
// Arbitrary margin - could be made customizable?
- gtk4_layer_shell.setMargin(window, edge, 20);
- gtk4_layer_shell.setAnchor(window, edge, false);
+ layer_shell.setMargin(window, edge, 20);
+ layer_shell.setAnchor(window, edge, false);
}
- if (self.apprt_window.isQuickTerminal()) {
- if (self.slide) |slide| slide.release();
-
- self.slide = if (anchored_edge) |anchored| slide: {
- const mgr = self.app_context.kde_slide_manager orelse break :slide null;
-
- const slide = mgr.create(self.surface) catch |err| {
- log.warn("could not create slide object={}", .{err});
- break :slide null;
- };
-
- const slide_location: org.KdeKwinSlide.Location = switch (anchored) {
- .top => .top,
- .bottom => .bottom,
- .left => .left,
- .right => .right,
- };
-
- slide.setLocation(@intCast(@intFromEnum(slide_location)));
- slide.commit();
- break :slide slide;
- } else null;
- }
+ if (self.slide) |slide| slide.release();
+
+ self.slide = if (anchored_edge) |anchored| slide: {
+ const mgr = self.app_context.kde_slide_manager orelse break :slide null;
+
+ const slide = mgr.create(self.surface) catch |err| {
+ log.warn("could not create slide object={}", .{err});
+ break :slide null;
+ };
+
+ const slide_location: org.KdeKwinSlide.Location = switch (anchored) {
+ .top => .top,
+ .bottom => .bottom,
+ .left => .left,
+ .right => .right,
+ };
+
+ slide.setLocation(@intCast(@intFromEnum(slide_location)));
+ slide.commit();
+ break :slide slide;
+ } else null;
}
/// Update the size of the quick terminal based on monitor dimensions.
@@ -412,17 +461,41 @@ pub const Window = struct {
apprt_window: *ApprtWindow,
) callconv(.c) void {
const window = apprt_window.window.as(gtk.Window);
- const size = apprt_window.config.quick_terminal_size;
- const position = apprt_window.config.quick_terminal_position;
+ const config = &apprt_window.config;
var monitor_size: gdk.Rectangle = undefined;
monitor.getGeometry(&monitor_size);
- const dims = size.calculate(position, .{
- .width = @intCast(monitor_size.f_width),
- .height = @intCast(monitor_size.f_height),
- });
+ const dims = config.quick_terminal_size.calculate(
+ config.quick_terminal_position,
+ .{
+ .width = @intCast(monitor_size.f_width),
+ .height = @intCast(monitor_size.f_height),
+ },
+ );
window.setDefaultSize(@intCast(dims.width), @intCast(dims.height));
}
+
+ fn onActivationTokenEvent(
+ token: *xdg.ActivationTokenV1,
+ event: xdg.ActivationTokenV1.Event,
+ self: *Window,
+ ) void {
+ const activation = self.app_context.xdg_activation orelse return;
+ const current_token = self.activation_token orelse return;
+
+ if (token.getId() != current_token.getId()) {
+ log.warn("received event for unknown activation token; ignoring", .{});
+ return;
+ }
+
+ switch (event) {
+ .done => |done| {
+ activation.activate(done.token, self.surface);
+ token.destroy();
+ self.activation_token = null;
+ },
+ }
+ }
};
diff --git a/src/apprt/gtk/winproto/x11.zig b/src/apprt/gtk/winproto/x11.zig
index c2b6bf416..624de03f8 100644
--- a/src/apprt/gtk/winproto/x11.zig
+++ b/src/apprt/gtk/winproto/x11.zig
@@ -36,16 +36,11 @@ pub const App = struct {
config: *const Config,
) !?App {
// If the display isn't X11, then we don't need to do anything.
- if (gobject.typeCheckInstanceIsA(
- gdk_display.as(gobject.TypeInstance),
- gdk_x11.X11Display.getGObjectType(),
- ) == 0) return null;
-
- // Get our X11 display
const gdk_x11_display = gobject.ext.cast(
gdk_x11.X11Display,
gdk_display,
) orelse return null;
+
const xlib_display = gdk_x11_display.getXdisplay();
const x11_program_name: [:0]const u8 = if (config.@"x11-instance-name") |pn|
@@ -109,7 +104,7 @@ pub const App = struct {
return .{
.display = xlib_display,
.base_event_code = base_event_code,
- .atoms = Atoms.init(gdk_x11_display),
+ .atoms = .init(gdk_x11_display),
};
}
@@ -176,8 +171,8 @@ pub const App = struct {
pub const Window = struct {
app: *App,
config: *const ApprtWindow.DerivedConfig,
- window: xlib.Window,
gtk_window: *adw.ApplicationWindow,
+ x11_surface: *gdk_x11.X11Surface,
blur_region: Region = .{},
@@ -192,13 +187,6 @@ pub const Window = struct {
gtk.Native,
).getSurface() orelse return error.NotX11Surface;
- // Check if we're actually on X11
- if (gobject.typeCheckInstanceIsA(
- surface.as(gobject.TypeInstance),
- gdk_x11.X11Surface.getGObjectType(),
- ) == 0)
- return error.NotX11Surface;
-
const x11_surface = gobject.ext.cast(
gdk_x11.X11Surface,
surface,
@@ -207,8 +195,8 @@ pub const Window = struct {
return .{
.app = app,
.config = &apprt_window.config,
- .window = x11_surface.getXid(),
.gtk_window = apprt_window.window,
+ .x11_surface = x11_surface,
};
}
@@ -279,7 +267,7 @@ pub const Window = struct {
const blur = self.config.background_blur;
log.debug("set blur={}, window xid={}, region={}", .{
blur,
- self.window,
+ self.x11_surface.getXid(),
self.blur_region,
});
@@ -335,11 +323,19 @@ pub const Window = struct {
pub fn addSubprocessEnv(self: *Window, env: *std.process.EnvMap) !void {
var buf: [64]u8 = undefined;
- const window_id = try std.fmt.bufPrint(&buf, "{}", .{self.window});
+ const window_id = try std.fmt.bufPrint(
+ &buf,
+ "{}",
+ .{self.x11_surface.getXid()},
+ );
try env.put("WINDOWID", window_id);
}
+ pub fn setUrgent(self: *Window, urgent: bool) !void {
+ self.x11_surface.setUrgencyHint(@intFromBool(urgent));
+ }
+
fn getWindowProperty(
self: *Window,
comptime T: type,
@@ -363,7 +359,7 @@ pub const Window = struct {
const code = c.XGetWindowProperty(
@ptrCast(@alignCast(self.app.display)),
- self.window,
+ self.x11_surface.getXid(),
name,
options.offset,
options.length,
@@ -401,7 +397,7 @@ pub const Window = struct {
const status = c.XChangeProperty(
@ptrCast(@alignCast(self.app.display)),
- self.window,
+ self.x11_surface.getXid(),
name,
typ,
@intFromEnum(format),
@@ -419,7 +415,7 @@ pub const Window = struct {
fn deleteProperty(self: *Window, name: c.Atom) X11Error!void {
const status = c.XDeleteProperty(
@ptrCast(@alignCast(self.app.display)),
- self.window,
+ self.x11_surface.getXid(),
name,
);
if (status == 0) return error.RequestFailed;
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 6de41c544..fcc67134b 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 {
@@ -74,7 +75,7 @@ pub const Message = union(enum) {
/// A terminal color was changed using OSC sequences.
color_change: struct {
- kind: terminal.osc.Command.ColorKind,
+ kind: terminal.osc.Command.ColorOperation.Kind,
color: terminal.color.RGB,
},
@@ -89,6 +90,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/GhosttyI18n.zig b/src/build/GhosttyI18n.zig
index daf523938..e0f6b5611 100644
--- a/src/build/GhosttyI18n.zig
+++ b/src/build/GhosttyI18n.zig
@@ -1,6 +1,7 @@
const GhosttyI18n = @This();
const std = @import("std");
+const builtin = @import("builtin");
const Config = @import("Config.zig");
const gresource = @import("../apprt/gtk/gresource.zig");
const internal_os = @import("../os/main.zig");
@@ -21,6 +22,14 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyI18n {
defer steps.deinit();
inline for (internal_os.i18n.locales) |locale| {
+ // There is no encoding suffix in the LC_MESSAGES path on FreeBSD,
+ // so we need to remove it from `locale` to have a correct destination string.
+ // (/usr/local/share/locale/en_AU/LC_MESSAGES)
+ const target_locale = comptime if (builtin.target.os.tag == .freebsd)
+ std.mem.trimRight(u8, locale, ".UTF-8")
+ else
+ locale;
+
const msgfmt = b.addSystemCommand(&.{ "msgfmt", "-o", "-" });
msgfmt.addFileArg(b.path("po/" ++ locale ++ ".po"));
@@ -28,7 +37,7 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyI18n {
msgfmt.captureStdOut(),
std.fmt.comptimePrint(
"share/locale/{s}/LC_MESSAGES/{s}.mo",
- .{ locale, domain },
+ .{ target_locale, domain },
),
).step);
}
@@ -54,7 +63,7 @@ fn createUpdateStep(b: *std.Build) !*std.Build.Step {
"--keyword=C_:1c,2",
"--package-name=" ++ domain,
"--msgid-bugs-address=m@mitchellh.com",
- "--copyright-holder=Mitchell Hashimoto",
+ "--copyright-holder=\"Mitchell Hashimoto, Ghostty contributors\"",
"-o",
"-",
});
diff --git a/src/build/GhosttyResources.zig b/src/build/GhosttyResources.zig
index 3d6b99a34..34b5e35f8 100644
--- a/src/build/GhosttyResources.zig
+++ b/src/build/GhosttyResources.zig
@@ -1,6 +1,8 @@
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");
@@ -16,6 +18,12 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyResources {
// Terminfo
terminfo: {
+ const os_tag = cfg.target.result.os.tag;
+ const terminfo_share_dir = if (os_tag == .freebsd)
+ "site-terminfo"
+ else
+ "terminfo";
+
// Encode our terminfo
var str = std.ArrayList(u8).init(b.allocator);
defer str.deinit();
@@ -26,12 +34,19 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyResources {
const source = wf.add("ghostty.terminfo", str.items);
if (cfg.emit_terminfo) {
- const source_install = b.addInstallFile(source, "share/terminfo/ghostty.terminfo");
+ const source_install = b.addInstallFile(
+ source,
+ if (os_tag == .freebsd)
+ "share/site-terminfo/ghostty.terminfo"
+ else
+ "share/terminfo/ghostty.terminfo",
+ );
+
try steps.append(&source_install.step);
}
// Windows doesn't have the binaries below.
- if (cfg.target.result.os.tag == .windows) break :terminfo;
+ if (os_tag == .windows) break :terminfo;
// Convert to termcap source format if thats helpful to people and
// install it. The resulting value here is the termcap source in case
@@ -43,7 +58,14 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyResources {
const out_source = run_step.captureStdOut();
_ = run_step.captureStdErr(); // so we don't see stderr
- const cap_install = b.addInstallFile(out_source, "share/terminfo/ghostty.termcap");
+ const cap_install = b.addInstallFile(
+ out_source,
+ if (os_tag == .freebsd)
+ "share/site-terminfo/ghostty.termcap"
+ else
+ "share/terminfo/ghostty.termcap",
+ );
+
try steps.append(&cap_install.step);
}
@@ -51,7 +73,8 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyResources {
{
const run_step = RunStep.create(b, "tic");
run_step.addArgs(&.{ "tic", "-x", "-o" });
- const path = run_step.addOutputFileArg("terminfo");
+ const path = run_step.addOutputFileArg(terminfo_share_dir);
+
run_step.addFileArg(source);
_ = run_step.captureStdErr(); // so we don't see stderr
@@ -63,7 +86,12 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyResources {
.windows => mkdir_step.addArgs(&.{"mkdir"}),
else => mkdir_step.addArgs(&.{ "mkdir", "-p" }),
}
- mkdir_step.addArg(b.fmt("{s}/share/terminfo", .{b.install_path}));
+
+ mkdir_step.addArg(b.fmt(
+ "{s}/share/{s}",
+ .{ b.install_path, terminfo_share_dir },
+ ));
+
try steps.append(&mkdir_step.step);
// Use cp -R instead of Step.InstallDir because we need to preserve
@@ -193,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(&copy.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/MetallibStep.zig b/src/build/MetallibStep.zig
index 12adf3edb..6999f8f31 100644
--- a/src/build/MetallibStep.zig
+++ b/src/build/MetallibStep.zig
@@ -22,13 +22,26 @@ step: *Step,
output: LazyPath,
pub fn create(b: *std.Build, opts: Options) ?*MetallibStep {
- const self = b.allocator.create(MetallibStep) catch @panic("OOM");
-
const sdk = switch (opts.target.result.os.tag) {
.macos => "macosx",
- .ios => "iphoneos",
+ .ios => switch (opts.target.result.abi) {
+ // The iOS simulator uses the same SDK for Metal as the device,
+ // but the minimum version tag causes different behaviors.
+ .simulator => "iphoneos",
+ else => "iphoneos",
+ },
else => return null,
};
+ const platform_version_arg = switch (opts.target.result.os.tag) {
+ .macos => "-mmacos-version-min",
+ .ios => switch (opts.target.result.abi) {
+ .simulator => "-mios-simulator-version-min",
+ else => "-mios-version-min",
+ },
+ else => null,
+ };
+
+ const self = b.allocator.create(MetallibStep) catch @panic("OOM");
const min_version = if (opts.target.query.os_version_min) |v|
b.fmt("{}", .{v.semver})
@@ -46,16 +59,11 @@ pub fn create(b: *std.Build, opts: Options) ?*MetallibStep {
const output_ir = run_ir.addOutputFileArg(b.fmt("{s}.ir", .{opts.name}));
run_ir.addArgs(&.{"-c"});
for (opts.sources) |source| run_ir.addFileArg(source);
- switch (opts.target.result.os.tag) {
- .ios => run_ir.addArgs(&.{b.fmt(
- "-mios-version-min={s}",
- .{min_version},
- )}),
- .macos => run_ir.addArgs(&.{b.fmt(
- "-mmacos-version-min={s}",
- .{min_version},
- )}),
- else => {},
+ if (platform_version_arg) |arg| {
+ run_ir.addArgs(&.{b.fmt(
+ "{s}={s}",
+ .{ arg, min_version },
+ )});
}
const run_lib = RunStep.create(
diff --git a/src/build/SharedDeps.zig b/src/build/SharedDeps.zig
index 0df261600..ec97a9c9f 100644
--- a/src/build/SharedDeps.zig
+++ b/src/build/SharedDeps.zig
@@ -24,9 +24,9 @@ pub const LazyPathList = std.ArrayList(std.Build.LazyPath);
pub fn init(b: *std.Build, cfg: *const Config) !SharedDeps {
var result: SharedDeps = .{
.config = cfg,
- .help_strings = try HelpStrings.init(b, cfg),
- .unicode_tables = try UnicodeTables.init(b),
- .framedata = try GhosttyFrameData.init(b),
+ .help_strings = try .init(b, cfg),
+ .unicode_tables = try .init(b),
+ .framedata = try .init(b),
// Setup by retarget
.options = undefined,
@@ -72,10 +72,10 @@ fn initTarget(
target: std.Build.ResolvedTarget,
) !void {
// Update our metallib
- self.metallib = MetallibStep.create(b, .{
+ 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
@@ -377,7 +377,7 @@ pub fn add(
// We always require the system SDK so that our system headers are available.
// This makes things like `os/log.h` available for cross-compiling.
if (step.rootModuleTarget().os.tag.isDarwin()) {
- try @import("apple_sdk").addPaths(b, step.root_module);
+ try @import("apple_sdk").addPaths(b, step);
const metallib = self.metallib.?;
metallib.output.addStepDependencies(&step.step);
@@ -609,21 +609,23 @@ fn addGTK(
.wayland_protocols = wayland_protocols_dep.path(""),
});
- // FIXME: replace with `zxdg_decoration_v1` once GTK merges https://gitlab.gnome.org/GNOME/gtk/-/merge_requests/6398
scanner.addCustomProtocol(
plasma_wayland_protocols_dep.path("src/protocols/blur.xml"),
);
+ // FIXME: replace with `zxdg_decoration_v1` once GTK merges https://gitlab.gnome.org/GNOME/gtk/-/merge_requests/6398
scanner.addCustomProtocol(
plasma_wayland_protocols_dep.path("src/protocols/server-decoration.xml"),
);
scanner.addCustomProtocol(
plasma_wayland_protocols_dep.path("src/protocols/slide.xml"),
);
+ scanner.addSystemProtocol("staging/xdg-activation/xdg-activation-v1.xml");
scanner.generate("wl_compositor", 1);
scanner.generate("org_kde_kwin_blur_manager", 1);
scanner.generate("org_kde_kwin_server_decoration_manager", 1);
scanner.generate("org_kde_kwin_slide_manager", 1);
+ scanner.generate("xdg_activation_v1", 1);
step.root_module.addImport("wayland", b.createModule(.{
.root_source_file = scanner.result,
@@ -650,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/build/mdgen/ghostty_1_footer.md b/src/build/mdgen/ghostty_1_footer.md
index 7ace64cd8..f8e502b45 100644
--- a/src/build/mdgen/ghostty_1_footer.md
+++ b/src/build/mdgen/ghostty_1_footer.md
@@ -44,6 +44,7 @@ See GitHub issues: <https://github.com/ghostty-org/ghostty/issues>
# AUTHOR
Mitchell Hashimoto <m@mitchellh.com>
+Ghostty contributors <https://github.com/ghostty-org/ghostty/graphs/contributors>
# SEE ALSO
diff --git a/src/build/mdgen/ghostty_5_footer.md b/src/build/mdgen/ghostty_5_footer.md
index c5077ab97..380d83a53 100644
--- a/src/build/mdgen/ghostty_5_footer.md
+++ b/src/build/mdgen/ghostty_5_footer.md
@@ -36,6 +36,7 @@ See GitHub issues: <https://github.com/ghostty-org/ghostty/issues>
# AUTHOR
Mitchell Hashimoto <m@mitchellh.com>
+Ghostty contributors <https://github.com/ghostty-org/ghostty/graphs/contributors>
# SEE ALSO
diff --git a/src/build/mdgen/mdgen.zig b/src/build/mdgen/mdgen.zig
index aca230aa5..e7d966323 100644
--- a/src/build/mdgen/mdgen.zig
+++ b/src/build/mdgen/mdgen.zig
@@ -26,7 +26,7 @@ pub fn genConfig(writer: anytype, cli: bool) !void {
\\
);
- @setEvalBranchQuota(3000);
+ @setEvalBranchQuota(5000);
inline for (@typeInfo(Config).@"struct".fields) |field| {
if (field.name[0] == '_') continue;
@@ -94,6 +94,7 @@ pub fn genKeybindActions(writer: anytype) !void {
const info = @typeInfo(KeybindAction);
std.debug.assert(info == .@"union");
+ @setEvalBranchQuota(5000);
inline for (info.@"union".fields) |field| {
if (field.name[0] == '_') continue;
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 4860cdd74..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.
@@ -84,7 +69,7 @@ pub fn parse(
// If the arena is unset, we create it. We mark that we own it
// only so that we can clean it up on error.
if (dst._arena == null) {
- dst._arena = ArenaAllocator.init(alloc);
+ dst._arena = .init(alloc);
arena_owned = true;
}
@@ -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,20 +454,10 @@ 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;
}
-fn parseTaggedUnion(comptime T: type, alloc: Allocator, v: []const u8) !T {
+pub fn parseTaggedUnion(comptime T: type, alloc: Allocator, v: []const u8) !T {
const info = @typeInfo(T).@"union";
assert(@typeInfo(info.tag_type.?) == .@"enum");
@@ -481,7 +524,7 @@ pub fn parseAutoStruct(comptime T: type, alloc: Allocator, v: []const u8) !T {
// Keep track of which fields were set so we can error if a required
// field was not set.
const FieldSet = std.StaticBitSet(info.fields.len);
- var fields_set: FieldSet = FieldSet.initEmpty();
+ var fields_set: FieldSet = .initEmpty();
// We split each value by ","
var iter = std.mem.splitSequence(u8, v, ",");
@@ -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);
@@ -1090,6 +1204,7 @@ test "parseIntoField: tagged union" {
b: u8,
c: void,
d: []const u8,
+ e: [:0]const u8,
} = undefined,
} = .{};
@@ -1108,6 +1223,10 @@ test "parseIntoField: tagged union" {
// Set string field
try parseIntoField(@TypeOf(data), alloc, &data, "value", "d:hello");
try testing.expectEqualStrings("hello", data.value.d);
+
+ // Set sentinel string field
+ try parseIntoField(@TypeOf(data), alloc, &data, "value", "e:hello");
+ try testing.expectEqualStrings("hello", data.value.e);
}
test "parseIntoField: tagged union unknown filed" {
@@ -1171,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 54f4c0969..e80a92286 100644
--- a/src/cli/list_themes.zig
+++ b/src/cli/list_themes.zig
@@ -77,7 +77,7 @@ const ThemeListElement = struct {
/// Two different directories will be searched for themes.
///
/// The first directory is the `themes` subdirectory of your Ghostty
-/// configuration directory. This is `$XDG_CONFIG_DIR/ghostty/themes` or
+/// configuration directory. This is `$XDG_CONFIG_HOME/ghostty/themes` or
/// `~/.config/ghostty/themes`.
///
/// The second directory is the `themes` subdirectory of the Ghostty resources
@@ -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 fb7359b3e..7f390fb08 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -3,6 +3,7 @@ const builtin = @import("builtin");
const formatter = @import("config/formatter.zig");
pub const Config = @import("config/Config.zig");
pub const conditional = @import("config/conditional.zig");
+pub const io = @import("config/io.zig");
pub const string = @import("config/string.zig");
pub const edit = @import("config/edit.zig");
pub const url = @import("config/url.zig");
@@ -30,8 +31,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 6f1e89d41..14ab5219d 100644
--- a/src/config/Config.zig
+++ b/src/config/Config.zig
@@ -34,6 +34,7 @@ const ErrorList = @import("ErrorList.zig");
const MetricModifier = fontpkg.Metrics.Modifier;
const help_strings = @import("help_strings");
pub const Command = @import("command.zig").Command;
+const RepeatableReadableIO = @import("io.zig").RepeatableReadableIO;
const RepeatableStringMap = @import("RepeatableStringMap.zig");
pub const Path = @import("path.zig").Path;
pub const RepeatablePath = @import("path.zig").RepeatablePath;
@@ -45,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.
@@ -266,6 +275,9 @@ pub const renamed = std.StaticStringMap([]const u8).initComptime(&.{
/// This affects the appearance of text and of any images with transparency.
/// Additionally, custom shaders will receive colors in the configured space.
///
+/// On macOS the default is `native`, on all other platforms the default is
+/// `linear-corrected`.
+///
/// Valid values:
///
/// * `native` - Perform alpha blending in the native color space for the OS.
@@ -276,12 +288,15 @@ pub const renamed = std.StaticStringMap([]const u8).initComptime(&.{
/// when certain color combinations are used (e.g. red / green), but makes
/// dark text look much thinner than normal and light text much thicker.
/// This is also sometimes known as "gamma correction".
-/// (Currently only supported on macOS. Has no effect on Linux.)
///
/// * `linear-corrected` - Same as `linear`, but with a correction step applied
/// for text that makes it look nearly or completely identical to `native`,
/// but without any of the darkening artifacts.
-@"alpha-blending": AlphaBlending = .native,
+@"alpha-blending": AlphaBlending =
+ if (builtin.os.tag == .macos)
+ .native
+ else
+ .@"linear-corrected",
/// All of the configurations behavior adjust various metrics determined by the
/// font. The values can be integers (1, -1, etc.) or a percentage (20%, -15%,
@@ -410,7 +425,7 @@ pub const renamed = std.StaticStringMap([]const u8).initComptime(&.{
/// include path separators unless it is an absolute pathname.
///
/// The first directory is the `themes` subdirectory of your Ghostty
-/// configuration directory. This is `$XDG_CONFIG_DIR/ghostty/themes` or
+/// configuration directory. This is `$XDG_CONFIG_HOME/ghostty/themes` or
/// `~/.config/ghostty/themes`.
///
/// The second directory is the `themes` subdirectory of the Ghostty resources
@@ -459,6 +474,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).
@@ -801,6 +903,47 @@ command: ?Command = null,
/// browser.
env: RepeatableStringMap = .{},
+/// Data to send as input to the command on startup.
+///
+/// The configured `command` will be launched using the typical rules,
+/// then the data specified as this input will be written to the pty
+/// before any other input can be provided.
+///
+/// The bytes are sent as-is with no additional encoding. Therefore, be
+/// cautious about input that can contain control characters, because this
+/// can be used to execute programs in a shell.
+///
+/// The format of this value is:
+///
+/// * `raw:<string>` - Send raw text as-is. This uses Zig string literal
+/// syntax so you can specify control characters and other standard
+/// escapes.
+///
+/// * `path:<path>` - Read a filepath and send the contents. The path
+/// must be to a file with finite length. e.g. don't use a device
+/// such as `/dev/stdin` or `/dev/urandom` as these will block
+/// terminal startup indefinitely. Files are limited to 10MB
+/// in size to prevent excessive memory usage. If you have files
+/// larger than this you should write a script to read the file
+/// and send it to the terminal.
+///
+/// If no valid prefix is found, it is assumed to be a `raw:` input.
+/// This is an ergonomic choice to allow you to simply write
+/// `input = "Hello, world!"` (a common case) without needing to prefix
+/// every value with `raw:`.
+///
+/// This can be repeated multiple times to send more data. The data
+/// is concatenated directly with no separator characters in between
+/// (e.g. no newline).
+///
+/// If any of the input sources do not exist, then none of the input
+/// will be sent. Input sources are not verified until the terminal
+/// is starting, so missing paths will not show up in config validation.
+///
+/// Changing this configuration at runtime will only affect new
+/// terminals.
+input: RepeatableReadableIO = .{},
+
/// If true, keep the terminal open after the command exits. Normally, the
/// terminal window closes when the running command (such as a shell) exits.
/// With this true, the terminal window will stay open until any keypress is
@@ -894,12 +1037,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).
///
@@ -1446,6 +1594,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.
@@ -1705,6 +1874,52 @@ keybind: Keybinds = .{},
/// window is ever created. Only implemented on Linux and macOS.
@"initial-window": bool = true,
+/// The duration that undo operations remain available. After this
+/// time, the operation will be removed from the undo stack and
+/// cannot be undone.
+///
+/// The default value is 5 seconds.
+///
+/// This timeout applies per operation, meaning that if you perform
+/// multiple operations, each operation will have its own timeout.
+/// New operations do not reset the timeout of previous operations.
+///
+/// A timeout of zero will effectively disable undo operations. It is
+/// not possible to set an infinite timeout, but you can set a very
+/// large timeout to effectively disable the timeout (on the order of years).
+/// This is highly discouraged, as it will cause the undo stack to grow
+/// indefinitely, memory usage to grow unbounded, and terminal sessions
+/// to never actually quit.
+///
+/// The duration is specified as a series of numbers followed by time units.
+/// Whitespace is allowed between numbers and units. Each number and unit will
+/// be added together to form the total duration.
+///
+/// The allowed time units are as follows:
+///
+/// * `y` - 365 SI days, or 8760 hours, or 31536000 seconds. No adjustments
+/// are made for leap years or leap seconds.
+/// * `d` - one SI day, or 86400 seconds.
+/// * `h` - one hour, or 3600 seconds.
+/// * `m` - one minute, or 60 seconds.
+/// * `s` - one second.
+/// * `ms` - one millisecond, or 0.001 second.
+/// * `us` or `µs` - one microsecond, or 0.000001 second.
+/// * `ns` - one nanosecond, or 0.000000001 second.
+///
+/// Examples:
+/// * `1h30m`
+/// * `45s`
+///
+/// Units can be repeated and will be added together. This means that
+/// `1h1h` is equivalent to `2h`. This is confusing and should be avoided.
+/// A future update may disallow this.
+///
+/// This configuration is only supported on macOS. Linux doesn't
+/// support undo operations at all so this configuration has no
+/// effect.
+@"undo-timeout": Duration = .{ .duration = 5 * std.time.ns_per_s },
+
/// The position of the "quick" terminal window. To learn more about the
/// quick terminal, see the documentation for the `toggle_quick_terminal`
/// binding action.
@@ -1777,7 +1992,7 @@ keybind: Keybinds = .{},
/// Automatically hide the quick terminal when focus shifts to another window.
/// Set it to false for the quick terminal to remain open even when it loses focus.
///
-/// Defaults to true on macOS and on false on Linux. This is because global
+/// Defaults to true on macOS and on false on Linux/BSD. This is because global
/// shortcuts on Linux require system configuration and are considerably less
/// accessible than on macOS, meaning that it is more preferable to keep the
/// quick terminal open until the user has completed their task.
@@ -1808,6 +2023,34 @@ keybind: Keybinds = .{},
/// On Linux the behavior is always equivalent to `move`.
@"quick-terminal-space-behavior": QuickTerminalSpaceBehavior = .move,
+/// Determines under which circumstances that the quick terminal should receive
+/// keyboard input. See the corresponding [Wayland documentation](https://wayland.app/protocols/wlr-layer-shell-unstable-v1#zwlr_layer_surface_v1:enum:keyboard_interactivity)
+/// for a more detailed explanation of the behavior of each option.
+///
+/// > [!NOTE]
+/// > The exact behavior of each option may differ significantly across
+/// > compositors -- experiment with them on your system to find one that
+/// > suits your liking!
+///
+/// Valid values are:
+///
+/// * `none`
+///
+/// The quick terminal will not receive any keyboard input.
+///
+/// * `on-demand` (default)
+///
+/// The quick terminal would only receive keyboard input when it is focused.
+///
+/// * `exclusive`
+///
+/// The quick terminal will always receive keyboard input, even when another
+/// window is currently focused.
+///
+/// Only has an effect on Linux Wayland.
+/// On macOS the behavior is always equivalent to `on-demand`.
+@"quick-terminal-keyboard-interactivity": QuickTerminalKeyboardInteractivity = .@"on-demand",
+
/// Whether to enable shell integration auto-injection or not. Shell integration
/// greatly enhances the terminal experience by enabling a number of features:
///
@@ -1853,6 +2096,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
@@ -1887,12 +2152,59 @@ keybind: Keybinds = .{},
/// causing the window to be completely black. If this happens, you can
/// unset this configuration to disable the shader.
///
-/// On Linux, this requires OpenGL 4.2. Ghostty typically only requires
-/// OpenGL 3.3, but custom shaders push that requirement up to 4.2.
+/// 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.
///
-/// 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.
+/// * `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
@@ -1903,8 +2215,7 @@ keybind: Keybinds = .{},
/// This can be repeated multiple times to load multiple shaders. The shaders
/// will be run in the order they are specified.
///
-/// Changing this value at runtime and reloading the configuration will only
-/// affect new windows, tabs, and splits.
+/// This can be changed at runtime and will affect all open terminals.
@"custom-shader": RepeatablePath = .{},
/// If `true` (default), the focused terminal surface will run an animation
@@ -1922,8 +2233,7 @@ keybind: Keybinds = .{},
/// will use more CPU per terminal surface and can become quite expensive
/// depending on the shader and your terminal usage.
///
-/// This value can be changed at runtime and will affect all currently
-/// open terminals.
+/// This can be changed at runtime and will affect all open terminals.
@"custom-shader-animation": CustomShaderAnimation = .true,
/// Bell features to enable if bell support is available in your runtime. Not
@@ -1935,7 +2245,7 @@ keybind: Keybinds = .{},
///
/// * `system`
///
-/// Instructs the system to notify the user using built-in system functions.
+/// Instruct the system to notify the user using built-in system functions.
/// This could result in an audiovisual effect, a notification, or something
/// else entirely. Changing these effects require altering system settings:
/// for instance under the "Sound > Alert Sound" setting in GNOME,
@@ -1945,15 +2255,31 @@ keybind: Keybinds = .{},
///
/// Play a custom sound. (GTK only)
///
-/// Example: `audio`, `no-audio`, `system`, `no-system`:
+/// * `attention` *(enabled by default)*
+///
+/// Request the user's attention when Ghostty is unfocused, until it has
+/// received focus again. On macOS, this will bounce the app icon in the
+/// dock once. On Linux, the behavior depends on the desktop environment
+/// and/or the window manager/compositor:
+///
+/// - On KDE, the background of the desktop icon in the task bar would be
+/// highlighted;
+///
+/// - On GNOME, you may receive a notification that, when clicked, would
+/// bring the Ghostty window into focus;
///
-/// On macOS, if the app is unfocused, it will bounce the app icon in the dock
-/// once. Additionally, the title of the window with the alerted terminal
-/// surface will contain a bell emoji (🔔) until the terminal is focused
-/// or a key is pressed. These are not currently configurable since they're
-/// considered unobtrusive.
+/// - On Sway, the window may be decorated with a distinctly colored border;
///
-/// By default, no bell features are enabled.
+/// - On other systems this may have no effect at all.
+///
+/// * `title` *(enabled by default)*
+///
+/// Prepend a bell emoji (🔔) to the title of the alerted surface until the
+/// terminal is re-focused or interacted with (such as on keyboard input).
+///
+/// Only implemented on macOS.
+///
+/// Example: `audio`, `no-audio`, `system`, `no-system`
@"bell-features": BellFeatures = .{},
/// If `audio` is an enabled bell feature, this is a path to an audio file. If
@@ -2025,6 +2351,25 @@ keybind: Keybinds = .{},
/// it will retain the previous setting until fullscreen is exited.
@"macos-non-native-fullscreen": NonNativeFullscreen = .false,
+/// Whether the window buttons in the macOS titlebar are visible. The window
+/// buttons are the colored buttons in the upper left corner of most macOS apps,
+/// also known as the traffic lights, that allow you to close, miniaturize, and
+/// zoom the window.
+///
+/// This setting has no effect when `window-decoration = false` or
+/// `macos-titlebar-style = hidden`, as the window buttons are always hidden in
+/// these modes.
+///
+/// Valid values are:
+///
+/// * `visible` - Show the window buttons.
+/// * `hidden` - Hide the window buttons.
+///
+/// The default value is `visible`.
+///
+/// Changing this option at runtime only applies to new windows.
+@"macos-window-buttons": MacWindowButtons = .visible,
+
/// The style of the macOS titlebar. Available values are: "native",
/// "transparent", "tabs", and "hidden".
///
@@ -2246,6 +2591,29 @@ keybind: Keybinds = .{},
///
@"macos-icon-screen-color": ?ColorList = null,
+/// Whether macOS Shortcuts are allowed to control Ghostty.
+///
+/// Ghostty exposes a number of actions that allow Shortcuts to
+/// control and interact with Ghostty. This includes creating new
+/// terminals, sending text to terminals, running commands, invoking
+/// any keybind action, etc.
+///
+/// This is a powerful feature but can be a security risk if a malicious
+/// shortcut is able to be installed and executed. Therefore, this
+/// configuration allows you to disable this feature.
+///
+/// Valid values are:
+///
+/// * `ask` - Ask the user whether for permission. Ghostty will remember
+/// this choice and never ask again. This is similar to other macOS
+/// permissions such as microphone access, camera access, etc.
+///
+/// * `allow` - Allow Shortcuts to control Ghostty without asking.
+///
+/// * `deny` - Deny Shortcuts from controlling Ghostty.
+///
+@"macos-shortcuts": MacShortcuts = .ask,
+
/// Put every surface (tab, split, window) into a dedicated Linux cgroup.
///
/// This makes it so that resource management can be done on a per-surface
@@ -2272,7 +2640,10 @@ keybind: Keybinds = .{},
/// * `single-instance` - Enable cgroups only for Ghostty instances launched
/// as single-instance applications (see gtk-single-instance).
///
-@"linux-cgroup": LinuxCgroup = .@"single-instance",
+@"linux-cgroup": LinuxCgroup = if (builtin.os.tag == .linux)
+ .@"single-instance"
+else
+ .never,
/// Memory limit for any individual terminal process (tab, split, window,
/// etc.) in bytes. If this is unset then no memory limit will be set.
@@ -2400,6 +2771,23 @@ term: []const u8 = "xterm-ghostty",
/// running. Defaults to an empty string if not set.
@"enquiry-response": []const u8 = "",
+/// The mechanism used to launch Ghostty. This should generally not be
+/// set by users, see the warning below.
+///
+/// WARNING: This is a low-level configuration that is not intended to be
+/// modified by users. All the values will be automatically detected as they
+/// are needed by Ghostty. This is only here in case our detection logic is
+/// incorrect for your environment or for developers who want to test
+/// Ghostty's behavior in different, forced environments.
+///
+/// This is set using the standard `no-[value]`, `[value]` syntax separated
+/// by commas. Example: "no-desktop,systemd". Specific details about the
+/// available values are documented on LaunchProperties in the code. Since
+/// this isn't intended to be modified by users, the documentation is
+/// lighter than the other configurations and users are expected to
+/// refer to the code for details.
+@"launched-from": ?LaunchSource = null,
+
/// Configures the low-level API to use for async IO, eventing, etc.
///
/// Most users should leave this set to `auto`. This will automatically detect
@@ -2531,7 +2919,7 @@ pub fn load(alloc_gpa: Allocator) !Config {
pub fn default(alloc_gpa: Allocator) Allocator.Error!Config {
// Build up our basic config
var result: Config = .{
- ._arena = ArenaAllocator.init(alloc_gpa),
+ ._arena = .init(alloc_gpa),
};
errdefer result.deinit();
const alloc = result._arena.?.allocator();
@@ -2539,6 +2927,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,
@@ -2564,24 +2955,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;
},
- }
- std.log.info("reading configuration file path={s}", .{path});
+ 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));
@@ -2636,13 +3023,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);
@@ -2662,13 +3049,109 @@ 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) {
.windows => {},
// Fast-path if we are Linux and have no args.
- .linux => if (std.os.argv.len <= 1) return,
+ .linux, .freebsd => if (std.os.argv.len <= 1) return,
// Everything else we have to at least try because it may
// not use std.os.argv.
@@ -2686,7 +3169,7 @@ pub fn loadCliArgs(self: *Config, alloc_gpa: Allocator) !void {
// styling, etc. based on the command.
//
// See: https://github.com/Vladimir-csp/xdg-terminal-exec
- if (comptime builtin.os.tag == .linux) {
+ if ((comptime builtin.os.tag == .linux) or (comptime builtin.os.tag == .freebsd)) {
if (internal_os.xdg.parseTerminalExec(std.os.argv)) |args| {
const arena_alloc = self._arena.?.allocator();
@@ -2722,19 +3205,18 @@ pub fn loadCliArgs(self: *Config, alloc_gpa: Allocator) !void {
// can replay if we are discarding the default files.
const replay_len_start = self._replay_steps.items.len;
- // Keep track of font families because if they are set from the CLI
- // then we clear the previously set values. This avoids a UX oddity
- // where on the CLI you have to specify `font-family=""` to clear the
- // font families before setting a new one.
+ // font-family settings set via the CLI overwrite any prior values
+ // rather than append. This avoids a UX oddity where you have to
+ // specify `font-family=""` to clear the font families.
const fields = &[_][]const u8{
"font-family",
"font-family-bold",
"font-family-italic",
"font-family-bold-italic",
};
- var counter: [fields.len]usize = undefined;
- inline for (fields, 0..) |field, i| {
- counter[i] = @field(self, field).list.items.len;
+ inline for (fields) |field| @field(self, field).overwrite_next = true;
+ defer {
+ inline for (fields) |field| @field(self, field).overwrite_next = false;
}
// Initialize our CLI iterator.
@@ -2759,28 +3241,6 @@ pub fn loadCliArgs(self: *Config, alloc_gpa: Allocator) !void {
try new_config.loadIter(alloc_gpa, &it);
self.deinit();
self.* = new_config;
- } else {
- // If any of our font family settings were changed, then we
- // replace the entire list with the new list.
- inline for (fields, 0..) |field, i| {
- const v = &@field(self, field);
-
- // The list can be empty if it was reset, i.e. --font-family=""
- if (v.list.items.len > 0) {
- const len = v.list.items.len - counter[i];
- if (len > 0) {
- // Note: we don't have to worry about freeing the memory
- // that we overwrite or cut off here because its all in
- // an arena.
- v.list.replaceRangeAssumeCapacity(
- 0,
- len,
- v.list.items[counter[i]..],
- );
- v.list.items.len = len;
- }
- }
- }
}
// Any paths referenced from the CLI are relative to the current working
@@ -2901,6 +3361,11 @@ pub fn loadRecursiveFiles(self: *Config, alloc_gpa: Allocator) !void {
}
}
+/// Get the arena allocator associated with the configuration.
+pub fn arenaAlloc(self: *Config) Allocator {
+ return self._arena.?.allocator();
+}
+
/// Change the state of conditionals and reload the configuration
/// based on the new state. This returns a new configuration based
/// on the new state. The caller must free the old configuration if they
@@ -2979,6 +3444,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 => {},
}
}
@@ -3106,6 +3580,11 @@ pub fn finalize(self: *Config) !void {
const alloc = self._arena.?.allocator();
+ // Ensure our launch source is properly set.
+ if (self.@"launched-from" == null) {
+ self.@"launched-from" = .detect();
+ }
+
// If we have a font-family set and don't set the others, default
// the others to the font family. This way, if someone does
// --font-family=foo, then we try to get the stylized versions of
@@ -3130,14 +3609,11 @@ pub fn finalize(self: *Config) !void {
}
// The default for the working directory depends on the system.
- const wd = self.@"working-directory" orelse wd: {
+ const wd = self.@"working-directory" orelse switch (self.@"launched-from".?) {
// If we have no working directory set, our default depends on
- // whether we were launched from the desktop or CLI.
- if (internal_os.launchedFromDesktop()) {
- break :wd "home";
- }
-
- break :wd "inherit";
+ // whether we were launched from the desktop or elsewhere.
+ .desktop => "home",
+ .cli, .dbus, .systemd => "inherit",
};
// If we are missing either a command or home directory, we need
@@ -3160,7 +3636,10 @@ pub fn finalize(self: *Config) !void {
// If we were launched from the desktop, our SHELL env var
// will represent our SHELL at login time. We want to use the
// latest shell from /etc/passwd or directory services.
- if (internal_os.launchedFromDesktop()) break :shell_env;
+ switch (self.@"launched-from".?) {
+ .desktop, .dbus, .systemd => break :shell_env,
+ .cli => {},
+ }
if (std.process.getEnvVarOwned(alloc, "SHELL")) |value| {
log.info("default shell source=env value={s}", .{value});
@@ -3321,6 +3800,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`
@@ -3332,7 +3832,7 @@ pub fn parseManuallyHook(
/// be deallocated while shallow clones exist.
pub fn shallowClone(self: *const Config, alloc_gpa: Allocator) Config {
var result = self.*;
- result._arena = ArenaAllocator.init(alloc_gpa);
+ result._arena = .init(alloc_gpa);
return result;
}
@@ -4172,6 +4672,11 @@ pub const RepeatableString = struct {
// Allocator for the list is the arena for the parent config.
list: std.ArrayListUnmanaged([:0]const u8) = .{},
+ // If true, then the next value will clear the list and start over
+ // rather than append. This is a bit of a hack but is here to make
+ // the font-family set of configurations work with CLI parsing.
+ overwrite_next: bool = false,
+
pub fn parseCLI(self: *Self, alloc: Allocator, input: ?[]const u8) !void {
const value = input orelse return error.ValueRequired;
@@ -4181,6 +4686,12 @@ pub const RepeatableString = struct {
return;
}
+ // If we're overwriting then we clear before appending
+ if (self.overwrite_next) {
+ self.list.clearRetainingCapacity();
+ self.overwrite_next = false;
+ }
+
const copy = try alloc.dupeZ(u8, value);
try self.list.append(alloc, copy);
}
@@ -4247,6 +4758,24 @@ pub const RepeatableString = struct {
try testing.expectEqual(@as(usize, 0), list.list.items.len);
}
+ test "parseCLI overwrite" {
+ const testing = std.testing;
+ var arena = ArenaAllocator.init(testing.allocator);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ var list: Self = .{};
+ try list.parseCLI(alloc, "A");
+
+ // Set our overwrite flag
+ list.overwrite_next = true;
+
+ try list.parseCLI(alloc, "B");
+ try testing.expectEqual(@as(usize, 1), list.list.items.len);
+ try list.parseCLI(alloc, "C");
+ try testing.expectEqual(@as(usize, 2), list.list.items.len);
+ }
+
test "formatConfig empty" {
const testing = std.testing;
var buf = std.ArrayList(u8).init(testing.allocator);
@@ -4505,6 +5034,12 @@ pub const Keybinds = struct {
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 }) },
.{ .write_screen_file = .paste },
);
@@ -4609,25 +5144,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,
@@ -4639,57 +5178,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
@@ -4760,22 +5309,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 },
);
}
@@ -4819,6 +5370,26 @@ pub const Keybinds = struct {
.{ .select_all = {} },
);
+ // Undo/redo
+ try self.set.putFlags(
+ alloc,
+ .{ .key = .{ .unicode = 't' }, .mods = .{ .super = true, .shift = true } },
+ .{ .undo = {} },
+ .{ .performable = true },
+ );
+ try self.set.putFlags(
+ alloc,
+ .{ .key = .{ .unicode = 'z' }, .mods = .{ .super = true } },
+ .{ .undo = {} },
+ .{ .performable = true },
+ );
+ try self.set.putFlags(
+ alloc,
+ .{ .key = .{ .unicode = 'z' }, .mods = .{ .super = true, .shift = true } },
+ .{ .redo = {} },
+ .{ .performable = true },
+ );
+
// Viewport scrolling
try self.set.put(
alloc,
@@ -5725,6 +6296,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,
@@ -5747,6 +6462,12 @@ pub const WindowColorspace = enum {
@"display-p3",
};
+/// See macos-window-buttons
+pub const MacWindowButtons = enum {
+ visible,
+ hidden,
+};
+
/// See macos-titlebar-style
pub const MacTitlebarStyle = enum {
native,
@@ -5793,6 +6514,13 @@ pub const MacAppIconFrame = enum {
chrome,
};
+/// See macos-shortcuts
+pub const MacShortcuts = enum {
+ allow,
+ deny,
+ ask,
+};
+
/// See gtk-single-instance
pub const GtkSingleInstance = enum {
desktop,
@@ -5804,7 +6532,6 @@ pub const GtkSingleInstance = enum {
pub const GtkTabsLocation = enum {
top,
bottom,
- hidden,
};
/// See gtk-toolbar-style
@@ -5823,6 +6550,8 @@ pub const AppNotifications = packed struct {
pub const BellFeatures = packed struct {
system: bool = false,
audio: bool = false,
+ attention: bool = true,
+ title: bool = true,
};
/// See mouse-shift-capture
@@ -5853,6 +6582,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,
@@ -5975,7 +6711,7 @@ pub const QuickTerminalSize = struct {
it.next() orelse return error.ValueRequired,
cli.args.whitespace,
);
- self.primary = try Size.parse(primary);
+ self.primary = try .parse(primary);
self.secondary = secondary: {
const secondary = std.mem.trim(
@@ -5983,7 +6719,7 @@ pub const QuickTerminalSize = struct {
it.next() orelse break :secondary null,
cli.args.whitespace,
);
- break :secondary try Size.parse(secondary);
+ break :secondary try .parse(secondary);
};
if (it.next()) |_| return error.TooManyArguments;
@@ -6138,6 +6874,13 @@ pub const QuickTerminalSpaceBehavior = enum {
move,
};
+/// See quick-terminal-keyboard-interactivity
+pub const QuickTerminalKeyboardInteractivity = enum {
+ none,
+ @"on-demand",
+ exclusive,
+};
+
/// See grapheme-width-method
pub const GraphemeWidthMethod = enum {
legacy,
@@ -6158,6 +6901,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
@@ -6465,7 +7230,7 @@ pub const Duration = struct {
if (remaining.len == 0) break;
// Find the longest number
- const number = number: {
+ const number: u64 = number: {
var prev_number: ?u64 = null;
var prev_remaining: ?[]const u8 = null;
for (1..remaining.len + 1) |index| {
@@ -6479,8 +7244,17 @@ pub const Duration = struct {
break :number prev_number;
} orelse return error.InvalidValue;
- // A number without a unit is invalid
- if (remaining.len == 0) return error.InvalidValue;
+ // A number without a unit is invalid unless the number is
+ // exactly zero. In that case, the unit is unambiguous since
+ // its all the same.
+ if (remaining.len == 0) {
+ if (number == 0) {
+ value = 0;
+ break;
+ }
+
+ return error.InvalidValue;
+ }
// Find the longest matching unit. Needs to be the longest matching
// to distinguish 'm' from 'ms'.
@@ -6554,6 +7328,34 @@ pub const Duration = struct {
}
};
+pub const LaunchSource = enum {
+ /// Ghostty was launched via the CLI. This is the default if
+ /// no other source is detected.
+ cli,
+
+ /// Ghostty was launched in a desktop environment (not via the CLI).
+ /// This is used to determine some behaviors such as how to read
+ /// settings, whether single instance defaults to true, etc.
+ desktop,
+
+ /// Ghostty was started via dbus activation.
+ dbus,
+
+ /// Ghostty was started via systemd activation.
+ systemd,
+
+ pub fn detect() LaunchSource {
+ return if (internal_os.launchedFromDesktop())
+ .desktop
+ else if (internal_os.launchedByDbusActivation())
+ .dbus
+ else if (internal_os.launchedBySystemd())
+ .systemd
+ else
+ .cli;
+ }
+};
+
pub const WindowPadding = struct {
const Self = @This();
@@ -6663,6 +7465,11 @@ test "parse duration" {
}
{
+ const d = try Duration.parseCLI("0");
+ try std.testing.expectEqual(@as(u64, 0), d.duration);
+ }
+
+ {
const d = try Duration.parseCLI("100ns");
try std.testing.expectEqual(@as(u64, 100), d.duration);
}
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/formatter.zig b/src/config/formatter.zig
index ca3da1d91..cabf80953 100644
--- a/src/config/formatter.zig
+++ b/src/config/formatter.zig
@@ -153,7 +153,7 @@ pub const FileFormatter = struct {
// If we're change-tracking then we need the default config to
// compare against.
var default: ?Config = if (self.changed)
- try Config.default(self.alloc)
+ try .default(self.alloc)
else
null;
defer if (default) |*v| v.deinit();
diff --git a/src/config/io.zig b/src/config/io.zig
new file mode 100644
index 000000000..8be4be551
--- /dev/null
+++ b/src/config/io.zig
@@ -0,0 +1,256 @@
+const std = @import("std");
+const assert = std.debug.assert;
+const Allocator = std.mem.Allocator;
+const ArenaAllocator = std.heap.ArenaAllocator;
+const string = @import("string.zig");
+const formatterpkg = @import("formatter.zig");
+const cli = @import("../cli.zig");
+
+/// ReadableIO is some kind of IO source that is readable.
+///
+/// It can be either a direct string or a filepath. The filepath will
+/// be deferred and read later, so it won't be checked for existence
+/// or readability at configuration time. This allows using a path that
+/// might be produced in an intermediate state.
+pub const ReadableIO = union(enum) {
+ const Self = @This();
+
+ raw: [:0]const u8,
+ path: [:0]const u8,
+
+ pub fn parseCLI(
+ self: *Self,
+ alloc: Allocator,
+ input_: ?[]const u8,
+ ) !void {
+ const input = input_ orelse return error.ValueRequired;
+ if (input.len == 0) return error.ValueRequired;
+
+ // We create a buffer only to do string parsing and validate
+ // it works. We store the value as raw so that our formatting
+ // can recreate it.
+ {
+ const buf = try alloc.alloc(u8, input.len);
+ defer alloc.free(buf);
+ _ = try string.parse(buf, input);
+ }
+
+ // Next, parse the tagged union using normal rules.
+ self.* = cli.args.parseTaggedUnion(
+ Self,
+ alloc,
+ input,
+ ) catch |err| switch (err) {
+ // Invalid values in the tagged union are interpreted as
+ // raw values. This lets users pass in simple string values
+ // without needing to tag them.
+ error.InvalidValue => .{ .raw = try alloc.dupeZ(u8, input) },
+ else => return err,
+ };
+ }
+
+ pub fn clone(self: Self, alloc: Allocator) Allocator.Error!Self {
+ return switch (self) {
+ .raw => |v| .{ .raw = try alloc.dupeZ(u8, v) },
+ .path => |v| .{ .path = try alloc.dupeZ(u8, v) },
+ };
+ }
+
+ /// Same as clone but also parses the values as Zig strings in
+ /// the final resulting value all at once so we can avoid extra
+ /// allocations.
+ pub fn cloneParsed(
+ self: Self,
+ alloc: Allocator,
+ ) Allocator.Error!Self {
+ switch (self) {
+ inline else => |v, tag| {
+ // Parsing can't fail because we validate it in parseCLI
+ const copied = try alloc.dupeZ(u8, v);
+ const parsed = string.parse(copied, v) catch unreachable;
+ assert(copied.ptr == parsed.ptr);
+
+ // If we parsed less than our original length we need
+ // to keep it null-terminated.
+ if (parsed.len < copied.len) copied[parsed.len] = 0;
+
+ return @unionInit(
+ Self,
+ @tagName(tag),
+ copied[0..parsed.len :0],
+ );
+ },
+ }
+ }
+
+ pub fn equal(self: Self, other: Self) bool {
+ if (std.meta.activeTag(self) != std.meta.activeTag(other)) {
+ return false;
+ }
+
+ return switch (self) {
+ .raw => |v| std.mem.eql(u8, v, other.raw),
+ .path => |v| std.mem.eql(u8, v, other.path),
+ };
+ }
+
+ pub fn formatEntry(self: Self, formatter: anytype) !void {
+ var buf: [4096]u8 = undefined;
+ var fbs = std.io.fixedBufferStream(&buf);
+ const writer = fbs.writer();
+ switch (self) {
+ inline else => |v, tag| {
+ writer.writeAll(@tagName(tag)) catch return error.OutOfMemory;
+ writer.writeByte(':') catch return error.OutOfMemory;
+ writer.writeAll(v) catch return error.OutOfMemory;
+ },
+ }
+
+ const written = fbs.getWritten();
+ try formatter.formatEntry(
+ []const u8,
+ written,
+ );
+ }
+
+ test "parseCLI" {
+ const testing = std.testing;
+ var arena = ArenaAllocator.init(testing.allocator);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+ {
+ var io: Self = undefined;
+ try Self.parseCLI(&io, alloc, "foo");
+ try testing.expect(io == .raw);
+ try testing.expectEqualStrings("foo", io.raw);
+ }
+ {
+ var io: Self = undefined;
+ try Self.parseCLI(&io, alloc, "raw:foo");
+ try testing.expect(io == .raw);
+ try testing.expectEqualStrings("foo", io.raw);
+ }
+ {
+ var io: Self = undefined;
+ try Self.parseCLI(&io, alloc, "path:foo");
+ try testing.expect(io == .path);
+ try testing.expectEqualStrings("foo", io.path);
+ }
+ }
+
+ test "formatEntry" {
+ const testing = std.testing;
+ var arena = ArenaAllocator.init(testing.allocator);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ var buf = std.ArrayList(u8).init(alloc);
+ defer buf.deinit();
+
+ var v: Self = undefined;
+ try v.parseCLI(alloc, "raw:foo");
+ try v.formatEntry(formatterpkg.entryFormatter("a", buf.writer()));
+ try std.testing.expectEqualSlices(u8, "a = raw:foo\n", buf.items);
+ }
+};
+
+pub const RepeatableReadableIO = struct {
+ const Self = @This();
+
+ // Allocator for the list is the arena for the parent config.
+ list: std.ArrayListUnmanaged(ReadableIO) = .{},
+
+ pub fn parseCLI(
+ self: *Self,
+ alloc: Allocator,
+ input: ?[]const u8,
+ ) !void {
+ const value = input orelse return error.ValueRequired;
+
+ // Empty value resets the list
+ if (value.len == 0) {
+ self.list.clearRetainingCapacity();
+ return;
+ }
+
+ var io: ReadableIO = undefined;
+ try ReadableIO.parseCLI(&io, alloc, value);
+ try self.list.append(alloc, io);
+ }
+
+ /// Deep copy of the struct. Required by Config.
+ pub fn clone(self: *const Self, alloc: Allocator) Allocator.Error!Self {
+ var list = try std.ArrayListUnmanaged(ReadableIO).initCapacity(
+ alloc,
+ self.list.items.len,
+ );
+ for (self.list.items) |item| {
+ const copy = try item.clone(alloc);
+ list.appendAssumeCapacity(copy);
+ }
+
+ return .{ .list = list };
+ }
+
+ /// See ReadableIO.cloneParsed
+ pub fn cloneParsed(
+ self: *const Self,
+ alloc: Allocator,
+ ) Allocator.Error!Self {
+ var list = try std.ArrayListUnmanaged(ReadableIO).initCapacity(
+ alloc,
+ self.list.items.len,
+ );
+ for (self.list.items) |item| {
+ const copy = try item.cloneParsed(alloc);
+ list.appendAssumeCapacity(copy);
+ }
+
+ return .{ .list = list };
+ }
+
+ /// Compare if two of our value are requal. Required by Config.
+ pub fn equal(self: Self, other: Self) bool {
+ const itemsA = self.list.items;
+ const itemsB = other.list.items;
+ if (itemsA.len != itemsB.len) return false;
+ for (itemsA, itemsB) |a, b| {
+ if (!a.equal(b)) return false;
+ } else return true;
+ }
+
+ /// Used by Formatter
+ pub fn formatEntry(
+ self: Self,
+ formatter: anytype,
+ ) !void {
+ if (self.list.items.len == 0) {
+ try formatter.formatEntry(void, {});
+ return;
+ }
+
+ for (self.list.items) |value| {
+ try formatter.formatEntry(ReadableIO, value);
+ }
+ }
+
+ test "parseCLI" {
+ const testing = std.testing;
+ var arena = ArenaAllocator.init(testing.allocator);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ var list: Self = .{};
+ try list.parseCLI(alloc, "raw:A");
+ try list.parseCLI(alloc, "path:B");
+ try testing.expectEqual(@as(usize, 2), list.list.items.len);
+
+ try list.parseCLI(alloc, "");
+ try testing.expectEqual(@as(usize, 0), list.list.items.len);
+ }
+};
+
+test {
+ _ = ReadableIO;
+ _ = RepeatableReadableIO;
+}
diff --git a/src/config/string.zig b/src/config/string.zig
index 5e0d40e55..71826f005 100644
--- a/src/config/string.zig
+++ b/src/config/string.zig
@@ -3,7 +3,7 @@ const std = @import("std");
/// Parse a string literal into a byte array. The string can contain
/// any valid Zig string literal escape sequences.
///
-/// The output buffer never needs sto be larger than the input buffer.
+/// The output buffer never needs to be larger than the input buffer.
/// The buffers may alias.
pub fn parse(out: []u8, bytes: []const u8) ![]u8 {
var dst_i: usize = 0;
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/crash/sentry.zig b/src/crash/sentry.zig
index c29184020..820c3e9a1 100644
--- a/src/crash/sentry.zig
+++ b/src/crash/sentry.zig
@@ -81,6 +81,13 @@ pub fn init(gpa: Allocator) !void {
fn initThread(gpa: Allocator) !void {
if (comptime !build_options.sentry) return;
+ // Right now, on Darwin, `std.Thread.setName` can only name the current
+ // thread, and we have no way to get the current thread from within it,
+ // so instead we use this code to name the thread instead.
+ if (builtin.os.tag.isDarwin()) {
+ internal_os.macos.pthread_setname_np(&"sentry-init".*);
+ }
+
var arena = std.heap.ArenaAllocator.init(gpa);
defer arena.deinit();
const alloc = arena.allocator();
diff --git a/src/crash/sentry_envelope.zig b/src/crash/sentry_envelope.zig
index 70eb99f51..6b675554c 100644
--- a/src/crash/sentry_envelope.zig
+++ b/src/crash/sentry_envelope.zig
@@ -331,7 +331,7 @@ pub const Item = union(enum) {
// Decode the item.
self.* = switch (encoded.type) {
- .attachment => .{ .attachment = try Attachment.decode(
+ .attachment => .{ .attachment = try .decode(
alloc,
encoded,
) },
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/datastruct/cache_table.zig b/src/datastruct/cache_table.zig
index 40d36cc24..fbfb30d71 100644
--- a/src/datastruct/cache_table.zig
+++ b/src/datastruct/cache_table.zig
@@ -70,7 +70,7 @@ pub fn CacheTable(
/// become a pointless check, but hopefully branch prediction picks
/// up on it at that point. The memory cost isn't too bad since it's
/// just bytes, so should be a fraction the size of the main table.
- lengths: [bucket_count]u8 = [_]u8{0} ** bucket_count,
+ lengths: [bucket_count]u8 = @splat(0),
/// An instance of the context structure.
/// Must be initialized before calling any operations.
diff --git a/src/datastruct/circ_buf.zig b/src/datastruct/circ_buf.zig
index 065bf6a1d..646a00940 100644
--- a/src/datastruct/circ_buf.zig
+++ b/src/datastruct/circ_buf.zig
@@ -152,7 +152,7 @@ pub fn CircBuf(comptime T: type, comptime default: T) type {
/// If larger, new values will be set to the default value.
pub fn resize(self: *Self, alloc: Allocator, size: usize) Allocator.Error!void {
// Rotate to zero so it is aligned.
- try self.rotateToZero(alloc);
+ try self.rotateToZero();
// Reallocate, this adds to the end so we're ready to go.
const prev_len = self.len();
@@ -173,29 +173,16 @@ pub fn CircBuf(comptime T: type, comptime default: T) type {
}
/// Rotate the data so that it is zero-aligned.
- fn rotateToZero(self: *Self, alloc: Allocator) Allocator.Error!void {
- // TODO: this does this in the worst possible way by allocating.
- // rewrite to not allocate, its possible, I'm just lazy right now.
-
+ fn rotateToZero(self: *Self) Allocator.Error!void {
// If we're already at zero then do nothing.
if (self.tail == 0) return;
- var buf = try alloc.alloc(T, self.storage.len);
- defer {
- self.head = if (self.full) 0 else self.len();
- self.tail = 0;
- alloc.free(self.storage);
- self.storage = buf;
- }
+ // We use std.mem.rotate to rotate our storage in-place.
+ std.mem.rotate(T, self.storage, self.tail);
- if (!self.full and self.head >= self.tail) {
- fastmem.copy(T, buf, self.storage[self.tail..self.head]);
- return;
- }
-
- const middle = self.storage.len - self.tail;
- fastmem.copy(T, buf, self.storage[self.tail..]);
- fastmem.copy(T, buf[middle..], self.storage[0..self.head]);
+ // Then fix up our head and tail.
+ self.head = self.len() % self.storage.len;
+ self.tail = 0;
}
/// Returns if the buffer is currently empty. To check if its
@@ -589,7 +576,7 @@ test "CircBuf rotateToZero" {
defer buf.deinit(alloc);
_ = buf.getPtrSlice(0, 11);
- try buf.rotateToZero(alloc);
+ try buf.rotateToZero();
}
test "CircBuf rotateToZero offset" {
@@ -611,7 +598,7 @@ test "CircBuf rotateToZero offset" {
try testing.expect(buf.tail > 0 and buf.head >= buf.tail);
// Rotate to zero
- try buf.rotateToZero(alloc);
+ try buf.rotateToZero();
try testing.expectEqual(@as(usize, 0), buf.tail);
try testing.expectEqual(@as(usize, 1), buf.head);
}
@@ -645,7 +632,7 @@ test "CircBuf rotateToZero wraps" {
}
// Rotate to zero
- try buf.rotateToZero(alloc);
+ try buf.rotateToZero();
try testing.expectEqual(@as(usize, 0), buf.tail);
try testing.expectEqual(@as(usize, 3), buf.head);
{
@@ -681,7 +668,7 @@ test "CircBuf rotateToZero full no wrap" {
}
// Rotate to zero
- try buf.rotateToZero(alloc);
+ try buf.rotateToZero();
try testing.expect(buf.full);
try testing.expectEqual(@as(usize, 0), buf.tail);
try testing.expectEqual(@as(usize, 0), buf.head);
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/CodepointResolver.zig b/src/font/CodepointResolver.zig
index 37093b59a..16536300c 100644
--- a/src/font/CodepointResolver.zig
+++ b/src/font/CodepointResolver.zig
@@ -37,7 +37,7 @@ collection: Collection,
/// The set of statuses and whether they're enabled or not. This defaults
/// to true. This can be changed at runtime with no ill effect.
-styles: StyleStatus = StyleStatus.initFill(true),
+styles: StyleStatus = .initFill(true),
/// If discovery is available, we'll look up fonts where we can't find
/// the codepoint. This can be set after initialization.
@@ -140,7 +140,7 @@ pub fn getIndex(
// handle this.
if (self.sprite) |sprite| {
if (sprite.hasCodepoint(cp, p)) {
- return Collection.Index.initSpecial(.sprite);
+ return .initSpecial(.sprite);
}
}
@@ -388,7 +388,7 @@ test getIndex {
{
errdefer c.deinit(alloc);
- _ = try c.add(alloc, .regular, .{ .loaded = try Face.init(
+ _ = try c.add(alloc, .regular, .{ .loaded = try .init(
lib,
testFont,
.{ .size = .{ .points = 12, .xdpi = 96, .ydpi = 96 } },
@@ -398,7 +398,7 @@ test getIndex {
_ = try c.add(
alloc,
.regular,
- .{ .loaded = try Face.init(
+ .{ .loaded = try .init(
lib,
testEmoji,
.{ .size = .{ .points = 12 } },
@@ -408,7 +408,7 @@ test getIndex {
_ = try c.add(
alloc,
.regular,
- .{ .loaded = try Face.init(
+ .{ .loaded = try .init(
lib,
testEmojiText,
.{ .size = .{ .points = 12 } },
@@ -467,17 +467,17 @@ test "getIndex disabled font style" {
var c = Collection.init();
c.load_options = .{ .library = lib };
- _ = try c.add(alloc, .regular, .{ .loaded = try Face.init(
+ _ = try c.add(alloc, .regular, .{ .loaded = try .init(
lib,
testFont,
.{ .size = .{ .points = 12, .xdpi = 96, .ydpi = 96 } },
) });
- _ = try c.add(alloc, .bold, .{ .loaded = try Face.init(
+ _ = try c.add(alloc, .bold, .{ .loaded = try .init(
lib,
testFont,
.{ .size = .{ .points = 12, .xdpi = 96, .ydpi = 96 } },
) });
- _ = try c.add(alloc, .italic, .{ .loaded = try Face.init(
+ _ = try c.add(alloc, .italic, .{ .loaded = try .init(
lib,
testFont,
.{ .size = .{ .points = 12, .xdpi = 96, .ydpi = 96 } },
diff --git a/src/font/Collection.zig b/src/font/Collection.zig
index 59f89d402..8533331bc 100644
--- a/src/font/Collection.zig
+++ b/src/font/Collection.zig
@@ -55,7 +55,7 @@ load_options: ?LoadOptions = null,
pub fn init() Collection {
// Initialize our styles array, preallocating some space that is
// likely to be used.
- return .{ .faces = StyleArray.initFill(.{}) };
+ return .{ .faces = .initFill(.{}) };
}
pub fn deinit(self: *Collection, alloc: Allocator) void {
@@ -707,7 +707,7 @@ test "add full" {
defer c.deinit(alloc);
for (0..Index.Special.start - 1) |_| {
- _ = try c.add(alloc, .regular, .{ .loaded = try Face.init(
+ _ = try c.add(alloc, .regular, .{ .loaded = try .init(
lib,
testFont,
.{ .size = .{ .points = 12 } },
@@ -755,7 +755,7 @@ test getFace {
var c = init();
defer c.deinit(alloc);
- const idx = try c.add(alloc, .regular, .{ .loaded = try Face.init(
+ const idx = try c.add(alloc, .regular, .{ .loaded = try .init(
lib,
testFont,
.{ .size = .{ .points = 12, .xdpi = 96, .ydpi = 96 } },
@@ -779,7 +779,7 @@ test getIndex {
var c = init();
defer c.deinit(alloc);
- _ = try c.add(alloc, .regular, .{ .loaded = try Face.init(
+ _ = try c.add(alloc, .regular, .{ .loaded = try .init(
lib,
testFont,
.{ .size = .{ .points = 12, .xdpi = 96, .ydpi = 96 } },
@@ -811,7 +811,7 @@ test completeStyles {
defer c.deinit(alloc);
c.load_options = .{ .library = lib };
- _ = try c.add(alloc, .regular, .{ .loaded = try Face.init(
+ _ = try c.add(alloc, .regular, .{ .loaded = try .init(
lib,
testFont,
.{ .size = .{ .points = 12, .xdpi = 96, .ydpi = 96 } },
@@ -838,7 +838,7 @@ test setSize {
defer c.deinit(alloc);
c.load_options = .{ .library = lib };
- _ = try c.add(alloc, .regular, .{ .loaded = try Face.init(
+ _ = try c.add(alloc, .regular, .{ .loaded = try .init(
lib,
testFont,
.{ .size = .{ .points = 12, .xdpi = 96, .ydpi = 96 } },
@@ -861,7 +861,7 @@ test hasCodepoint {
defer c.deinit(alloc);
c.load_options = .{ .library = lib };
- const idx = try c.add(alloc, .regular, .{ .loaded = try Face.init(
+ const idx = try c.add(alloc, .regular, .{ .loaded = try .init(
lib,
testFont,
.{ .size = .{ .points = 12, .xdpi = 96, .ydpi = 96 } },
@@ -885,7 +885,7 @@ test "hasCodepoint emoji default graphical" {
defer c.deinit(alloc);
c.load_options = .{ .library = lib };
- const idx = try c.add(alloc, .regular, .{ .loaded = try Face.init(
+ const idx = try c.add(alloc, .regular, .{ .loaded = try .init(
lib,
testEmoji,
.{ .size = .{ .points = 12, .xdpi = 96, .ydpi = 96 } },
@@ -908,7 +908,7 @@ test "metrics" {
defer c.deinit(alloc);
c.load_options = .{ .library = lib };
- _ = try c.add(alloc, .regular, .{ .loaded = try Face.init(
+ _ = try c.add(alloc, .regular, .{ .loaded = try .init(
lib,
testFont,
.{ .size = .{ .points = 12, .xdpi = 96, .ydpi = 96 } },
diff --git a/src/font/DeferredFace.zig b/src/font/DeferredFace.zig
index 8794ccea9..f9ce0bff5 100644
--- a/src/font/DeferredFace.zig
+++ b/src/font/DeferredFace.zig
@@ -254,7 +254,7 @@ fn loadWebCanvas(
opts: font.face.Options,
) !Face {
const wc = self.wc.?;
- return try Face.initNamed(wc.alloc, wc.font_str, opts, wc.presentation);
+ return try .initNamed(wc.alloc, wc.font_str, opts, wc.presentation);
}
/// Returns true if this face can satisfy the given codepoint and
diff --git a/src/font/SharedGrid.zig b/src/font/SharedGrid.zig
index 72e97fad8..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 };
@@ -319,7 +352,7 @@ fn testGrid(mode: TestMode, alloc: Allocator, lib: Library) !SharedGrid {
switch (mode) {
.normal => {
- _ = try c.add(alloc, .regular, .{ .loaded = try Face.init(
+ _ = try c.add(alloc, .regular, .{ .loaded = try .init(
lib,
testFont,
.{ .size = .{ .points = 12, .xdpi = 96, .ydpi = 96 } },
diff --git a/src/font/SharedGridSet.zig b/src/font/SharedGridSet.zig
index 8ad30629e..e3e61907b 100644
--- a/src/font/SharedGridSet.zig
+++ b/src/font/SharedGridSet.zig
@@ -126,7 +126,7 @@ pub fn ref(
.ref = 1,
};
- grid.* = try SharedGrid.init(self.alloc, resolver: {
+ grid.* = try .init(self.alloc, resolver: {
// Build our collection. This is the expensive operation that
// involves finding fonts, loading them (maybe, some are deferred),
// etc.
@@ -258,7 +258,7 @@ fn collection(
_ = try c.add(
self.alloc,
.regular,
- .{ .fallback_loaded = try Face.init(
+ .{ .fallback_loaded = try .init(
self.font_lib,
font.embedded.regular,
load_options.faceOptions(),
@@ -267,7 +267,7 @@ fn collection(
_ = try c.add(
self.alloc,
.bold,
- .{ .fallback_loaded = try Face.init(
+ .{ .fallback_loaded = try .init(
self.font_lib,
font.embedded.bold,
load_options.faceOptions(),
@@ -276,7 +276,7 @@ fn collection(
_ = try c.add(
self.alloc,
.italic,
- .{ .fallback_loaded = try Face.init(
+ .{ .fallback_loaded = try .init(
self.font_lib,
font.embedded.italic,
load_options.faceOptions(),
@@ -285,7 +285,7 @@ fn collection(
_ = try c.add(
self.alloc,
.bold_italic,
- .{ .fallback_loaded = try Face.init(
+ .{ .fallback_loaded = try .init(
self.font_lib,
font.embedded.bold_italic,
load_options.faceOptions(),
@@ -318,7 +318,7 @@ fn collection(
_ = try c.add(
self.alloc,
.regular,
- .{ .fallback_loaded = try Face.init(
+ .{ .fallback_loaded = try .init(
self.font_lib,
font.embedded.emoji,
load_options.faceOptions(),
@@ -327,7 +327,7 @@ fn collection(
_ = try c.add(
self.alloc,
.regular,
- .{ .fallback_loaded = try Face.init(
+ .{ .fallback_loaded = try .init(
self.font_lib,
font.embedded.emoji_text,
load_options.faceOptions(),
@@ -391,7 +391,7 @@ fn discover(self: *SharedGridSet) !?*Discover {
// If we initialized, use it
if (self.font_discover) |*v| return v;
- self.font_discover = Discover.init();
+ self.font_discover = .init();
return &self.font_discover.?;
}
@@ -498,7 +498,7 @@ pub const Key = struct {
/// each style. For example, bold is from
/// offsets[@intFromEnum(.bold) - 1] to
/// offsets[@intFromEnum(.bold)].
- style_offsets: StyleOffsets = .{0} ** style_offsets_len,
+ style_offsets: StyleOffsets = @splat(0),
/// The codepoint map configuration.
codepoint_map: CodepointMap = .{},
diff --git a/src/font/discovery.zig b/src/font/discovery.zig
index 384799da5..9284f9486 100644
--- a/src/font/discovery.zig
+++ b/src/font/discovery.zig
@@ -4,6 +4,7 @@ const Allocator = std.mem.Allocator;
const assert = std.debug.assert;
const fontconfig = @import("fontconfig");
const macos = @import("macos");
+const opentype = @import("opentype.zig");
const options = @import("main.zig").options;
const Collection = @import("main.zig").Collection;
const DeferredFace = @import("main.zig").DeferredFace;
@@ -562,149 +563,266 @@ pub const CoreText = struct {
desc: *const Descriptor,
list: []*macos.text.FontDescriptor,
) void {
- var desc_mut = desc.*;
- if (desc_mut.style == null) {
- // If there is no explicit style set, we set a preferred
- // based on the style bool attributes.
- //
- // TODO: doesn't handle i18n font names well, we should have
- // another mechanism that uses the weight attribute if it exists.
- // Wait for this to be a real problem.
- desc_mut.style = if (desc_mut.bold and desc_mut.italic)
- "Bold Italic"
- else if (desc_mut.bold)
- "Bold"
- else if (desc_mut.italic)
- "Italic"
- else
- null;
- }
-
- std.mem.sortUnstable(*macos.text.FontDescriptor, list, &desc_mut, struct {
+ std.mem.sortUnstable(*macos.text.FontDescriptor, list, desc, struct {
fn lessThan(
desc_inner: *const Descriptor,
lhs: *macos.text.FontDescriptor,
rhs: *macos.text.FontDescriptor,
) bool {
- const lhs_score = score(desc_inner, lhs);
- const rhs_score = score(desc_inner, rhs);
+ const lhs_score: Score = .score(desc_inner, lhs);
+ const rhs_score: Score = .score(desc_inner, rhs);
// Higher score is "less" (earlier)
return lhs_score.int() > rhs_score.int();
}
}.lessThan);
}
- /// We represent our sorting score as a packed struct so that we can
- /// compare scores numerically but build scores symbolically.
+ /// We represent our sorting score as a packed struct so that we
+ /// can compare scores numerically but build scores symbolically.
+ ///
+ /// Note that packed structs store their fields from least to most
+ /// significant, so the fields here are defined in increasing order
+ /// of precedence.
const Score = packed struct {
const Backing = @typeInfo(@This()).@"struct".backing_integer.?;
- glyph_count: u16 = 0, // clamped if > intmax
- traits: Traits = .unmatched,
- style: Style = .unmatched,
+ /// Number of glyphs in the font, if two fonts have identical
+ /// scores otherwise then we prefer the one with more glyphs.
+ ///
+ /// (Number of glyphs clamped at u16 intmax)
+ glyph_count: u16 = 0,
+ /// A fuzzy match on the style string, less important than
+ /// an exact match, and less important than trait matches.
+ fuzzy_style: u8 = 0,
+ /// Whether the bold-ness of the font matches the descriptor.
+ /// This is less important than italic because a font that's italic
+ /// when it shouldn't be or not italic when it should be is a bigger
+ /// problem (subjectively) than being the wrong weight.
+ bold: bool = false,
+ /// Whether the italic-ness of the font matches the descriptor.
+ /// This is less important than an exact match on the style string
+ /// because we want users to be allowed to override trait matching
+ /// for the bold/italic/bold italic styles if they want.
+ italic: bool = false,
+ /// An exact (case-insensitive) match on the style string.
+ exact_style: bool = false,
+ /// Whether the font is monospace, this is more important than any of
+ /// the other fields unless we're looking for a specific codepoint,
+ /// in which case that is the most important thing.
monospace: bool = false,
+ /// If we're looking for a codepoint, whether this font has it.
codepoint: bool = false,
- const Traits = enum(u8) { unmatched = 0, _ };
- const Style = enum(u8) { unmatched = 0, match = 0xFF, _ };
-
pub fn int(self: Score) Backing {
return @bitCast(self);
}
- };
- fn score(desc: *const Descriptor, ct_desc: *const macos.text.FontDescriptor) Score {
- var score_acc: Score = .{};
-
- // We always load the font if we can since some things can only be
- // inspected on the font itself.
- const font_: ?*macos.text.Font = macos.text.Font.createWithFontDescriptor(
- ct_desc,
- 12,
- ) catch null;
- defer if (font_) |font| font.release();
-
- // If we have a font, prefer the font with more glyphs.
- if (font_) |font| {
- const Type = @TypeOf(score_acc.glyph_count);
- score_acc.glyph_count = std.math.cast(
- Type,
- font.getGlyphCount(),
- ) orelse std.math.maxInt(Type);
- }
+ fn score(desc: *const Descriptor, ct_desc: *const macos.text.FontDescriptor) Score {
+ var self: Score = .{};
+
+ // We always load the font if we can since some things can only be
+ // inspected on the font itself. Fonts that can't be loaded score
+ // 0 automatically because we don't want a font we can't load.
+ const font: *macos.text.Font = macos.text.Font.createWithFontDescriptor(
+ ct_desc,
+ 12,
+ ) catch return self;
+ defer font.release();
+
+ // We prefer fonts with more glyphs, all else being equal.
+ {
+ const Type = @TypeOf(self.glyph_count);
+ self.glyph_count = std.math.cast(
+ Type,
+ font.getGlyphCount(),
+ ) orelse std.math.maxInt(Type);
+ }
- // If we're searching for a codepoint, prioritize fonts that
- // have that codepoint.
- if (desc.codepoint > 0) codepoint: {
- const font = font_ orelse break :codepoint;
+ // If we're searching for a codepoint, then we
+ // prioritize fonts that have that codepoint.
+ if (desc.codepoint > 0) {
+ // Turn UTF-32 into UTF-16 for CT API
+ var unichars: [2]u16 = undefined;
+ const pair = macos.foundation.stringGetSurrogatePairForLongCharacter(
+ desc.codepoint,
+ &unichars,
+ );
+ const len: usize = if (pair) 2 else 1;
+
+ // Get our glyphs
+ var glyphs = [2]macos.graphics.Glyph{ 0, 0 };
+ self.codepoint = font.getGlyphsForCharacters(
+ unichars[0..len],
+ glyphs[0..len],
+ );
+ }
- // Turn UTF-32 into UTF-16 for CT API
- var unichars: [2]u16 = undefined;
- const pair = macos.foundation.stringGetSurrogatePairForLongCharacter(
- desc.codepoint,
- &unichars,
- );
- const len: usize = if (pair) 2 else 1;
+ // Get our symbolic traits for the descriptor so we can
+ // compare boolean attributes like bold, monospace, etc.
+ const symbolic_traits: macos.text.FontSymbolicTraits = traits: {
+ const traits = ct_desc.copyAttribute(.traits) orelse break :traits .{};
+ defer traits.release();
- // Get our glyphs
- var glyphs = [2]macos.graphics.Glyph{ 0, 0 };
- score_acc.codepoint = font.getGlyphsForCharacters(unichars[0..len], glyphs[0..len]);
- }
+ const key = macos.text.FontTraitKey.symbolic.key();
+ const symbolic = traits.getValue(macos.foundation.Number, key) orelse
+ break :traits .{};
- // Get our symbolic traits for the descriptor so we can compare
- // boolean attributes like bold, monospace, etc.
- const symbolic_traits: macos.text.FontSymbolicTraits = traits: {
- const traits = ct_desc.copyAttribute(.traits) orelse break :traits .{};
- defer traits.release();
+ break :traits macos.text.FontSymbolicTraits.init(symbolic);
+ };
- const key = macos.text.FontTraitKey.symbolic.key();
- const symbolic = traits.getValue(macos.foundation.Number, key) orelse
- break :traits .{};
+ self.monospace = symbolic_traits.monospace;
+
+ // We try to derived data from the font itself, which is generally
+ // more reliable than only using the symbolic traits for this.
+ const is_bold: bool, const is_italic: bool = derived: {
+ // We start with initial guesses based on the symbolic traits,
+ // but refine these with more information if we can get it.
+ var is_italic = symbolic_traits.italic;
+ var is_bold = symbolic_traits.bold;
+
+ // Read the 'head' table out of the font data if it's available.
+ if (head: {
+ const tag = macos.text.FontTableTag.init("head");
+ const data = font.copyTable(tag) orelse break :head null;
+ defer data.release();
+ const ptr = data.getPointer();
+ const len = data.getLength();
+ break :head opentype.Head.init(ptr[0..len]) catch |err| {
+ log.warn("error parsing head table: {}", .{err});
+ break :head null;
+ };
+ }) |head_| {
+ const head: opentype.Head = head_;
+ is_bold = is_bold or (head.macStyle & 1 == 1);
+ is_italic = is_italic or (head.macStyle & 2 == 2);
+ }
- break :traits macos.text.FontSymbolicTraits.init(symbolic);
- };
+ // Read the 'OS/2' table out of the font data if it's available.
+ if (os2: {
+ const tag = macos.text.FontTableTag.init("OS/2");
+ const data = font.copyTable(tag) orelse break :os2 null;
+ defer data.release();
+ const ptr = data.getPointer();
+ const len = data.getLength();
+ break :os2 opentype.OS2.init(ptr[0..len]) catch |err| {
+ log.warn("error parsing OS/2 table: {}", .{err});
+ break :os2 null;
+ };
+ }) |os2| {
+ is_bold = is_bold or os2.fsSelection.bold;
+ is_italic = is_italic or os2.fsSelection.italic;
+ }
- score_acc.monospace = symbolic_traits.monospace;
+ // Check if we have variation axes in our descriptor, if we
+ // do then we can derive weight italic-ness or both from them.
+ if (font.copyAttribute(.variation_axes)) |axes| variations: {
+ defer axes.release();
+
+ // Copy the variation values for this instance of the font.
+ // if there are none then we just break out immediately.
+ const values: *macos.foundation.Dictionary =
+ font.copyAttribute(.variation) orelse break :variations;
+ defer values.release();
+
+ var buf: [1024]u8 = undefined;
+
+ // If we see the 'ital' value then we ignore 'slnt'.
+ var ital_seen = false;
+
+ const len = axes.getCount();
+ for (0..len) |i| {
+ const dict = axes.getValueAtIndex(macos.foundation.Dictionary, i);
+ const Key = macos.text.FontVariationAxisKey;
+ const cf_id = dict.getValue(Key.identifier.Value(), Key.identifier.key()).?;
+ const cf_name = dict.getValue(Key.name.Value(), Key.name.key()).?;
+ const cf_def = dict.getValue(Key.default_value.Value(), Key.default_value.key()).?;
+
+ const name_str = cf_name.cstring(&buf, .utf8) orelse "";
+
+ // Default value
+ var def: f64 = 0;
+ _ = cf_def.getValue(.double, &def);
+ // Value in this font
+ var val: f64 = def;
+ if (values.getValue(
+ macos.foundation.Number,
+ cf_id,
+ )) |cf_val| _ = cf_val.getValue(.double, &val);
+
+ if (std.mem.eql(u8, "wght", name_str)) {
+ // Somewhat subjective threshold, we consider fonts
+ // bold if they have a 'wght' set greater than 600.
+ is_bold = val > 600;
+ continue;
+ }
+ if (std.mem.eql(u8, "ital", name_str)) {
+ is_italic = val > 0.5;
+ ital_seen = true;
+ continue;
+ }
+ if (!ital_seen and std.mem.eql(u8, "slnt", name_str)) {
+ // Arbitrary threshold of anything more than a 5
+ // degree clockwise slant is considered italic.
+ is_italic = val <= -5.0;
+ continue;
+ }
+ }
+ }
- score_acc.style = style: {
- const style = ct_desc.copyAttribute(.style_name) orelse
- break :style .unmatched;
- defer style.release();
+ break :derived .{ is_bold, is_italic };
+ };
- // Get our style string
- var buf: [128]u8 = undefined;
- const style_str = style.cstring(&buf, .utf8) orelse break :style .unmatched;
-
- // If we have a specific desired style, attempt to search for that.
- if (desc.style) |desired_style| {
- // Matching style string gets highest score
- if (std.mem.eql(u8, desired_style, style_str)) break :style .match;
- } else if (!desc.bold and !desc.italic) {
- // If we do not, and we have no symbolic traits, then we try
- // to find "regular" (or no style). If we have symbolic traits
- // we do nothing but we can improve scoring by taking that into
- // account, too.
- if (std.mem.eql(u8, "Regular", style_str)) {
- break :style .match;
- }
- }
+ self.bold = desc.bold == is_bold;
+ self.italic = desc.italic == is_italic;
- // Otherwise the score is based on the length of the style string.
- // Shorter styles are scored higher. This is a heuristic that
- // if we don't have a desired style then shorter tends to be
- // more often the "regular" style.
- break :style @enumFromInt(100 -| style_str.len);
- };
+ // Get the style string from the font.
+ var style_str_buf: [128]u8 = undefined;
+ const style_str: []const u8 = style_str: {
+ const style = ct_desc.copyAttribute(.style_name) orelse
+ break :style_str "";
+ defer style.release();
- score_acc.traits = traits: {
- var count: u8 = 0;
- if (desc.bold == symbolic_traits.bold) count += 1;
- if (desc.italic == symbolic_traits.italic) count += 1;
- break :traits @enumFromInt(count);
- };
+ break :style_str style.cstring(&style_str_buf, .utf8) orelse "";
+ };
- return score_acc;
- }
+ // The first string in this slice will be used for the exact match,
+ // and for the fuzzy match, all matching substrings will increase
+ // the rank.
+ const desired_styles: []const [:0]const u8 = desired: {
+ if (desc.style) |s| break :desired &.{s};
+
+ // If we don't have an explicitly desired style name, we base
+ // it on the bold and italic properties, this isn't ideal since
+ // fonts may use style names other than these, but it helps in
+ // some edge cases.
+ if (desc.bold) {
+ if (desc.italic) break :desired &.{ "bold italic", "bold", "italic", "oblique" };
+ break :desired &.{ "bold", "upright" };
+ } else if (desc.italic) {
+ break :desired &.{ "italic", "regular", "oblique" };
+ }
+ break :desired &.{ "regular", "upright" };
+ };
+
+ self.exact_style = std.ascii.eqlIgnoreCase(
+ style_str,
+ desired_styles[0],
+ );
+ // Our "fuzzy match" score is 0 if the desired style isn't present
+ // in the string, otherwise we give higher priority for styles that
+ // have fewer characters not in the desired_styles list.
+ const fuzzy_type = @TypeOf(self.fuzzy_style);
+ self.fuzzy_style = @intCast(style_str.len);
+ for (desired_styles) |s| {
+ if (std.ascii.indexOfIgnoreCase(style_str, s) != null) {
+ self.fuzzy_style -|= @intCast(s.len);
+ }
+ }
+ self.fuzzy_style = std.math.maxInt(fuzzy_type) -| self.fuzzy_style;
+
+ return self;
+ }
+ };
pub const DiscoverIterator = struct {
alloc: Allocator,
@@ -837,3 +955,85 @@ test "coretext codepoint" {
// Should have other codepoints too
try testing.expect(face.hasCodepoint('B', null));
}
+
+test "coretext sorting" {
+ if (options.backend != .coretext and options.backend != .coretext_freetype)
+ return error.SkipZigTest;
+
+ // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!//
+ // FIXME: Disabled for now because SF Pro is not available in CI
+ // The solution likely involves directly testing that the
+ // `sortMatchingDescriptors` function sorts a bundled test
+ // font correctly, instead of relying on the system fonts.
+ if (true) return error.SkipZigTest;
+ // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!//
+
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var ct = CoreText.init();
+ defer ct.deinit();
+
+ // We try to get a Regular, Italic, Bold, & Bold Italic version of SF Pro,
+ // which should be installed on all Macs, and has many styles which makes
+ // it a good test, since there will be many results for each discovery.
+
+ // Regular
+ {
+ var it = try ct.discover(alloc, .{
+ .family = "SF Pro",
+ .size = 12,
+ });
+ defer it.deinit();
+ const res = (try it.next()).?;
+ var buf: [1024]u8 = undefined;
+ const name = try res.name(&buf);
+ try testing.expectEqualStrings("SF Pro Regular", name);
+ }
+
+ // Regular Italic
+ //
+ // NOTE: This makes sure that we don't accidentally prefer "Thin Italic",
+ // which we previously did, because it has a shorter name.
+ {
+ var it = try ct.discover(alloc, .{
+ .family = "SF Pro",
+ .size = 12,
+ .italic = true,
+ });
+ defer it.deinit();
+ const res = (try it.next()).?;
+ var buf: [1024]u8 = undefined;
+ const name = try res.name(&buf);
+ try testing.expectEqualStrings("SF Pro Regular Italic", name);
+ }
+
+ // Bold
+ {
+ var it = try ct.discover(alloc, .{
+ .family = "SF Pro",
+ .size = 12,
+ .bold = true,
+ });
+ defer it.deinit();
+ const res = (try it.next()).?;
+ var buf: [1024]u8 = undefined;
+ const name = try res.name(&buf);
+ try testing.expectEqualStrings("SF Pro Bold", name);
+ }
+
+ // Bold Italic
+ {
+ var it = try ct.discover(alloc, .{
+ .family = "SF Pro",
+ .size = 12,
+ .bold = true,
+ .italic = true,
+ });
+ defer it.deinit();
+ const res = (try it.next()).?;
+ var buf: [1024]u8 = undefined;
+ const name = try res.name(&buf);
+ try testing.expectEqualStrings("SF Pro Bold Italic", name);
+ }
+}
diff --git a/src/font/face/coretext.zig b/src/font/face/coretext.zig
index 639eae43c..06bba661f 100644
--- a/src/font/face/coretext.zig
+++ b/src/font/face/coretext.zig
@@ -97,7 +97,7 @@ pub const Face = struct {
errdefer if (comptime harfbuzz_shaper) hb_font.destroy();
const color: ?ColorState = if (traits.color_glyphs)
- try ColorState.init(ct_font)
+ try .init(ct_font)
else
null;
errdefer if (color) |v| v.deinit();
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/face/freetype_convert.zig b/src/font/face/freetype_convert.zig
index 6df350bfa..3a7cf8c98 100644
--- a/src/font/face/freetype_convert.zig
+++ b/src/font/face/freetype_convert.zig
@@ -30,7 +30,7 @@ fn genMap() Map {
// Initialize to no converter
var i: usize = 0;
while (i < freetype.c.FT_PIXEL_MODE_MAX) : (i += 1) {
- result[i] = AtlasArray.initFill(null);
+ result[i] = .initFill(null);
}
// Map our converters
diff --git a/src/font/shaper/coretext.zig b/src/font/shaper/coretext.zig
index f2ac5b85d..8e2c45c69 100644
--- a/src/font/shaper/coretext.zig
+++ b/src/font/shaper/coretext.zig
@@ -191,7 +191,7 @@ pub const Shaper = struct {
// Create the CF release thread.
var cf_release_thread = try alloc.create(CFReleaseThread);
errdefer alloc.destroy(cf_release_thread);
- cf_release_thread.* = try CFReleaseThread.init(alloc);
+ cf_release_thread.* = try .init(alloc);
errdefer cf_release_thread.deinit();
// Start the CF release thread.
@@ -1768,7 +1768,7 @@ fn testShaperWithFont(alloc: Allocator, font_req: TestFont) !TestShaper {
c.load_options = .{ .library = lib };
// Setup group
- _ = try c.add(alloc, .regular, .{ .loaded = try Face.init(
+ _ = try c.add(alloc, .regular, .{ .loaded = try .init(
lib,
testFont,
.{ .size = .{ .points = 12 } },
@@ -1776,7 +1776,7 @@ fn testShaperWithFont(alloc: Allocator, font_req: TestFont) !TestShaper {
if (font.options.backend != .coretext) {
// Coretext doesn't support Noto's format
- _ = try c.add(alloc, .regular, .{ .loaded = try Face.init(
+ _ = try c.add(alloc, .regular, .{ .loaded = try .init(
lib,
testEmoji,
.{ .size = .{ .points = 12 } },
@@ -1795,7 +1795,7 @@ fn testShaperWithFont(alloc: Allocator, font_req: TestFont) !TestShaper {
errdefer face.deinit();
_ = try c.add(alloc, .regular, .{ .deferred = face });
}
- _ = try c.add(alloc, .regular, .{ .loaded = try Face.init(
+ _ = try c.add(alloc, .regular, .{ .loaded = try .init(
lib,
testEmojiText,
.{ .size = .{ .points = 12 } },
@@ -1803,7 +1803,7 @@ fn testShaperWithFont(alloc: Allocator, font_req: TestFont) !TestShaper {
const grid_ptr = try alloc.create(SharedGrid);
errdefer alloc.destroy(grid_ptr);
- grid_ptr.* = try SharedGrid.init(alloc, .{ .collection = c });
+ grid_ptr.* = try .init(alloc, .{ .collection = c });
errdefer grid_ptr.*.deinit(alloc);
var shaper = try Shaper.init(alloc, .{});
diff --git a/src/font/shaper/feature.zig b/src/font/shaper/feature.zig
index 8e70d51da..66d0cb1f7 100644
--- a/src/font/shaper/feature.zig
+++ b/src/font/shaper/feature.zig
@@ -21,7 +21,7 @@ pub const Feature = struct {
pub fn fromString(str: []const u8) ?Feature {
var fbs = std.io.fixedBufferStream(str);
const reader = fbs.reader();
- return Feature.fromReader(reader);
+ return .fromReader(reader);
}
/// Parse a single font feature setting from a std.io.Reader, with a version
@@ -35,190 +35,156 @@ pub const Feature = struct {
///
/// Ref: https://harfbuzz.github.io/harfbuzz-hb-common.html#hb-feature-from-string
pub fn fromReader(reader: anytype) ?Feature {
- var tag: [4]u8 = undefined;
+ var tag_buf: [4]u8 = undefined;
+ var tag: []u8 = tag_buf[0..0];
var value: ?u32 = null;
- // TODO: when we move to Zig 0.14 this can be replaced with a
- // labeled switch continue pattern rather than this loop.
- var state: union(enum) {
+ state: switch ((enum {
/// Initial state.
- start: void,
- /// Parsing the tag, data is index.
- tag: u2,
+ start,
+ /// Parsing the tag.
+ tag,
/// In the space between the tag and the value.
- space: void,
+ space,
/// Parsing an integer parameter directly in to `value`.
- int: void,
+ int,
/// Parsing a boolean keyword parameter ("on"/"off").
- bool: void,
+ bool,
/// Encountered an unrecoverable syntax error, advancing to boundary.
- err: void,
- /// Done parsing feature.
- done: void,
- } = .start;
- while (true) {
- // If we hit the end of the stream we just pretend it's a comma.
- const byte = reader.readByte() catch ',';
- switch (state) {
- // If we're done then we skip whitespace until we see a ','.
- .done => switch (byte) {
- ' ', '\t' => continue,
- ',' => break,
- // If we see something other than whitespace or a ','
- // then this is an error since the intent is unclear.
- else => {
- state = .err;
- continue;
- },
- },
+ err,
+ /// Done parsing feature, skip whitespace until end.
+ done,
+ }).start) {
+ // If we're done then we skip whitespace until we see a ','.
+ .done => while (true) switch (reader.readByte() catch ',') {
+ ' ', '\t' => continue,
+ ',' => break,
+ // If we see something other than whitespace or a ','
+ // then this is an error since the intent is unclear.
+ else => continue :state .err,
+ },
- // If we're fast-forwarding from an error we just wanna
- // stop at the first boundary and ignore all other bytes.
- .err => if (byte == ',') return null,
+ // If we're fast-forwarding from an error we just wanna
+ // stop at the first boundary and ignore all other bytes.
+ .err => {
+ reader.skipUntilDelimiterOrEof(',') catch {};
+ return null;
+ },
- .start => switch (byte) {
- // Ignore leading whitespace.
- ' ', '\t' => continue,
- // Empty feature string.
- ',' => return null,
- // '+' prefix to explicitly enable feature.
- '+' => {
- value = 1;
- state = .{ .tag = 0 };
- continue;
- },
- // '-' prefix to explicitly disable feature.
- '-' => {
- value = 0;
- state = .{ .tag = 0 };
- continue;
- },
- // Quote mark introducing a tag.
- '"', '\'' => {
- state = .{ .tag = 0 };
- continue;
- },
- // First letter of tag.
- else => {
- tag[0] = byte;
- state = .{ .tag = 1 };
- continue;
- },
+ .start => while (true) switch (reader.readByte() catch ',') {
+ // Ignore leading whitespace.
+ ' ', '\t' => continue,
+ // Empty feature string.
+ ',' => return null,
+ // '+' prefix to explicitly enable feature.
+ '+' => {
+ value = 1;
+ continue :state .tag;
+ },
+ // '-' prefix to explicitly disable feature.
+ '-' => {
+ value = 0;
+ continue :state .tag;
+ },
+ // Quote mark introducing a tag.
+ '"', '\'' => {
+ continue :state .tag;
+ },
+ // First letter of tag.
+ else => |byte| {
+ tag.len = 1;
+ tag[0] = byte;
+ continue :state .tag;
},
+ },
- .tag => |*i| switch (byte) {
- // If the tag is interrupted by a comma it's invalid.
- ',' => return null,
- // Ignore quote marks.
- '"', '\'' => continue,
- // A prefix of '+' or '-'
- // In all other cases we add the byte to our tag.
- else => {
- tag[i.*] = byte;
- if (i.* == 3) {
- state = .space;
- continue;
- }
- i.* += 1;
- },
+ .tag => while (true) switch (reader.readByte() catch ',') {
+ // If the tag is interrupted by a comma it's invalid.
+ ',' => return null,
+ // Ignore quote marks. This does technically ignore cases like
+ // "'k'e'r'n' = 0", but it's unambiguous so if someone really
+ // wants to do that in their config then... sure why not.
+ '"', '\'' => continue,
+ // In all other cases we add the byte to our tag.
+ else => |byte| {
+ tag.len += 1;
+ tag[tag.len - 1] = byte;
+ if (tag.len == 4) continue :state .space;
},
+ },
- .space => switch (byte) {
- ' ', '\t' => continue,
- // Ignore quote marks since we might have a
- // closing quote from the tag still ahead.
- '"', '\'' => continue,
- // Allow an '=' (which we can safely ignore)
- // only if we don't already have a value due
- // to a '+' or '-' prefix.
- '=' => if (value != null) {
- state = .err;
- continue;
- },
- ',' => {
- // Specifying only a tag turns a feature on.
- if (value == null) value = 1;
- break;
- },
- '0'...'9' => {
- // If we already have value because of a
- // '+' or '-' prefix then this is an error.
- if (value != null) {
- state = .err;
- continue;
- }
- value = byte - '0';
- state = .int;
- continue;
- },
- 'o', 'O' => {
- // If we already have value because of a
- // '+' or '-' prefix then this is an error.
- if (value != null) {
- state = .err;
- continue;
- }
- state = .bool;
- continue;
- },
- else => {
- state = .err;
- continue;
- },
+ .space => while (true) switch (reader.readByte() catch ',') {
+ ' ', '\t' => continue,
+ // Ignore quote marks since we might have a
+ // closing quote from the tag still ahead.
+ '"', '\'' => continue,
+ // Allow an '=' (which we can safely ignore)
+ // only if we don't already have a value due
+ // to a '+' or '-' prefix.
+ '=' => if (value != null) continue :state .err,
+ ',' => {
+ // Specifying only a tag turns a feature on.
+ if (value == null) value = 1;
+ break;
},
+ '0'...'9' => |byte| {
+ // If we already have value because of a
+ // '+' or '-' prefix then this is an error.
+ if (value != null) continue :state .err;
+ value = byte - '0';
+ continue :state .int;
+ },
+ 'o', 'O' => {
+ // If we already have value because of a
+ // '+' or '-' prefix then this is an error.
+ if (value != null) continue :state .err;
+ continue :state .bool;
+ },
+ else => continue :state .err,
+ },
- .int => switch (byte) {
- ',' => break,
- '0'...'9' => {
- // If our value gets too big while
- // parsing we consider it an error.
- value = std.math.mul(u32, value.?, 10) catch {
- state = .err;
- continue;
- };
- value.? += byte - '0';
- },
- else => {
- state = .err;
- continue;
- },
+ .int => while (true) switch (reader.readByte() catch ',') {
+ ',' => break,
+ '0'...'9' => |byte| {
+ // If our value gets too big while
+ // parsing we consider it an error.
+ value = std.math.mul(u32, value.?, 10) catch {
+ continue :state .err;
+ };
+ value.? += byte - '0';
},
+ else => continue :state .err,
+ },
- .bool => switch (byte) {
- ',' => return null,
- 'n', 'N' => {
- // "ofn"
- if (value != null) {
- assert(value == 0);
- state = .err;
- continue;
- }
- value = 1;
- state = .done;
- continue;
- },
- 'f', 'F' => {
- // To make sure we consume two 'f's.
- if (value == null) {
- value = 0;
- } else {
- assert(value == 0);
- state = .done;
- continue;
- }
- },
- else => {
- state = .err;
- continue;
- },
+ .bool => while (true) switch (reader.readByte() catch ',') {
+ ',' => return null,
+ 'n', 'N' => {
+ // "ofn"
+ if (value != null) {
+ assert(value == 0);
+ continue :state .err;
+ }
+ value = 1;
+ continue :state .done;
+ },
+ 'f', 'F' => {
+ // To make sure we consume two 'f's.
+ if (value == null) {
+ value = 0;
+ } else {
+ assert(value == 0);
+ continue :state .done;
+ }
},
- }
+ else => continue :state .err,
+ },
}
assert(value != null);
+ assert(tag.len == 4);
return .{
- .tag = tag,
+ .tag = tag_buf,
.value = value.?,
};
}
diff --git a/src/font/shaper/harfbuzz.zig b/src/font/shaper/harfbuzz.zig
index eb8130f79..361cbbe93 100644
--- a/src/font/shaper/harfbuzz.zig
+++ b/src/font/shaper/harfbuzz.zig
@@ -1227,7 +1227,7 @@ fn testShaperWithFont(alloc: Allocator, font_req: TestFont) !TestShaper {
c.load_options = .{ .library = lib };
// Setup group
- _ = try c.add(alloc, .regular, .{ .loaded = try Face.init(
+ _ = try c.add(alloc, .regular, .{ .loaded = try .init(
lib,
testFont,
.{ .size = .{ .points = 12 } },
@@ -1235,7 +1235,7 @@ fn testShaperWithFont(alloc: Allocator, font_req: TestFont) !TestShaper {
if (comptime !font.options.backend.hasCoretext()) {
// Coretext doesn't support Noto's format
- _ = try c.add(alloc, .regular, .{ .loaded = try Face.init(
+ _ = try c.add(alloc, .regular, .{ .loaded = try .init(
lib,
testEmoji,
.{ .size = .{ .points = 12 } },
@@ -1254,7 +1254,7 @@ fn testShaperWithFont(alloc: Allocator, font_req: TestFont) !TestShaper {
errdefer face.deinit();
_ = try c.add(alloc, .regular, .{ .deferred = face });
}
- _ = try c.add(alloc, .regular, .{ .loaded = try Face.init(
+ _ = try c.add(alloc, .regular, .{ .loaded = try .init(
lib,
testEmojiText,
.{ .size = .{ .points = 12 } },
@@ -1262,7 +1262,7 @@ fn testShaperWithFont(alloc: Allocator, font_req: TestFont) !TestShaper {
const grid_ptr = try alloc.create(SharedGrid);
errdefer alloc.destroy(grid_ptr);
- grid_ptr.* = try SharedGrid.init(alloc, .{ .collection = c });
+ grid_ptr.* = try .init(alloc, .{ .collection = c });
errdefer grid_ptr.*.deinit(alloc);
var shaper = try Shaper.init(alloc, .{});
diff --git a/src/font/sprite/Box.zig b/src/font/sprite/Box.zig
index 68acdabe5..f5140091d 100644
--- a/src/font/sprite/Box.zig
+++ b/src/font/sprite/Box.zig
@@ -516,40 +516,40 @@ fn draw(self: Box, alloc: Allocator, canvas: *font.sprite.Canvas, cp: u32) !void
0x257f => self.draw_lines(canvas, .{ .up = .heavy, .down = .light }),
// '▀' UPPER HALF BLOCK
- 0x2580 => self.draw_block(canvas, Alignment.upper, 1, half),
+ 0x2580 => self.draw_block(canvas, .upper, 1, half),
// '▁' LOWER ONE EIGHTH BLOCK
- 0x2581 => self.draw_block(canvas, Alignment.lower, 1, one_eighth),
+ 0x2581 => self.draw_block(canvas, .lower, 1, one_eighth),
// '▂' LOWER ONE QUARTER BLOCK
- 0x2582 => self.draw_block(canvas, Alignment.lower, 1, one_quarter),
+ 0x2582 => self.draw_block(canvas, .lower, 1, one_quarter),
// '▃' LOWER THREE EIGHTHS BLOCK
- 0x2583 => self.draw_block(canvas, Alignment.lower, 1, three_eighths),
+ 0x2583 => self.draw_block(canvas, .lower, 1, three_eighths),
// '▄' LOWER HALF BLOCK
- 0x2584 => self.draw_block(canvas, Alignment.lower, 1, half),
+ 0x2584 => self.draw_block(canvas, .lower, 1, half),
// '▅' LOWER FIVE EIGHTHS BLOCK
- 0x2585 => self.draw_block(canvas, Alignment.lower, 1, five_eighths),
+ 0x2585 => self.draw_block(canvas, .lower, 1, five_eighths),
// '▆' LOWER THREE QUARTERS BLOCK
- 0x2586 => self.draw_block(canvas, Alignment.lower, 1, three_quarters),
+ 0x2586 => self.draw_block(canvas, .lower, 1, three_quarters),
// '▇' LOWER SEVEN EIGHTHS BLOCK
- 0x2587 => self.draw_block(canvas, Alignment.lower, 1, seven_eighths),
+ 0x2587 => self.draw_block(canvas, .lower, 1, seven_eighths),
// '█' FULL BLOCK
0x2588 => self.draw_full_block(canvas),
// '▉' LEFT SEVEN EIGHTHS BLOCK
- 0x2589 => self.draw_block(canvas, Alignment.left, seven_eighths, 1),
+ 0x2589 => self.draw_block(canvas, .left, seven_eighths, 1),
// '▊' LEFT THREE QUARTERS BLOCK
- 0x258a => self.draw_block(canvas, Alignment.left, three_quarters, 1),
+ 0x258a => self.draw_block(canvas, .left, three_quarters, 1),
// '▋' LEFT FIVE EIGHTHS BLOCK
- 0x258b => self.draw_block(canvas, Alignment.left, five_eighths, 1),
+ 0x258b => self.draw_block(canvas, .left, five_eighths, 1),
// '▌' LEFT HALF BLOCK
- 0x258c => self.draw_block(canvas, Alignment.left, half, 1),
+ 0x258c => self.draw_block(canvas, .left, half, 1),
// '▍' LEFT THREE EIGHTHS BLOCK
- 0x258d => self.draw_block(canvas, Alignment.left, three_eighths, 1),
+ 0x258d => self.draw_block(canvas, .left, three_eighths, 1),
// '▎' LEFT ONE QUARTER BLOCK
- 0x258e => self.draw_block(canvas, Alignment.left, one_quarter, 1),
+ 0x258e => self.draw_block(canvas, .left, one_quarter, 1),
// '▏' LEFT ONE EIGHTH BLOCK
- 0x258f => self.draw_block(canvas, Alignment.left, one_eighth, 1),
+ 0x258f => self.draw_block(canvas, .left, one_eighth, 1),
// '▐' RIGHT HALF BLOCK
- 0x2590 => self.draw_block(canvas, Alignment.right, half, 1),
+ 0x2590 => self.draw_block(canvas, .right, half, 1),
// '░'
0x2591 => self.draw_light_shade(canvas),
// '▒'
@@ -557,9 +557,9 @@ fn draw(self: Box, alloc: Allocator, canvas: *font.sprite.Canvas, cp: u32) !void
// '▓'
0x2593 => self.draw_dark_shade(canvas),
// '▔' UPPER ONE EIGHTH BLOCK
- 0x2594 => self.draw_block(canvas, Alignment.upper, 1, one_eighth),
+ 0x2594 => self.draw_block(canvas, .upper, 1, one_eighth),
// '▕' RIGHT ONE EIGHTH BLOCK
- 0x2595 => self.draw_block(canvas, Alignment.right, one_eighth, 1),
+ 0x2595 => self.draw_block(canvas, .right, one_eighth, 1),
// '▖'
0x2596 => self.draw_quadrant(canvas, .{ .bl = true }),
// '▗'
@@ -581,6 +581,120 @@ fn draw(self: Box, alloc: Allocator, canvas: *font.sprite.Canvas, cp: u32) !void
// '▟'
0x259f => self.draw_quadrant(canvas, .{ .tr = true, .bl = true, .br = true }),
+ // '◢'
+ 0x25e2 => self.draw_corner_triangle_shade(canvas, .br, .on),
+ // '◣'
+ 0x25e3 => self.draw_corner_triangle_shade(canvas, .bl, .on),
+ // '◤'
+ 0x25e4 => self.draw_corner_triangle_shade(canvas, .tl, .on),
+ // '◥'
+ 0x25e5 => self.draw_corner_triangle_shade(canvas, .tr, .on),
+
+ // '◸'
+ 0x25f8 => {
+ const thickness_px = Thickness.light.height(self.metrics.box_thickness);
+ // top edge
+ self.rect(
+ canvas,
+ 0,
+ 0,
+ self.metrics.cell_width,
+ thickness_px,
+ );
+ // left edge
+ self.rect(
+ canvas,
+ 0,
+ 0,
+ thickness_px,
+ self.metrics.cell_height -| 1,
+ );
+ // diagonal
+ self.draw_cell_diagonal(
+ canvas,
+ .lower_left,
+ .upper_right,
+ );
+ },
+ // '◹'
+ 0x25f9 => {
+ const thickness_px = Thickness.light.height(self.metrics.box_thickness);
+ // top edge
+ self.rect(
+ canvas,
+ 0,
+ 0,
+ self.metrics.cell_width,
+ thickness_px,
+ );
+ // right edge
+ self.rect(
+ canvas,
+ self.metrics.cell_width -| thickness_px,
+ 0,
+ self.metrics.cell_width,
+ self.metrics.cell_height -| 1,
+ );
+ // diagonal
+ self.draw_cell_diagonal(
+ canvas,
+ .upper_left,
+ .lower_right,
+ );
+ },
+ // '◺'
+ 0x25fa => {
+ const thickness_px = Thickness.light.height(self.metrics.box_thickness);
+ // bottom edge
+ self.rect(
+ canvas,
+ 0,
+ self.metrics.cell_height -| thickness_px,
+ self.metrics.cell_width,
+ self.metrics.cell_height,
+ );
+ // left edge
+ self.rect(
+ canvas,
+ 0,
+ 1,
+ thickness_px,
+ self.metrics.cell_height,
+ );
+ // diagonal
+ self.draw_cell_diagonal(
+ canvas,
+ .upper_left,
+ .lower_right,
+ );
+ },
+ // '◿'
+ 0x25ff => {
+ const thickness_px = Thickness.light.height(self.metrics.box_thickness);
+ // bottom edge
+ self.rect(
+ canvas,
+ 0,
+ self.metrics.cell_height -| thickness_px,
+ self.metrics.cell_width,
+ self.metrics.cell_height,
+ );
+ // right edge
+ self.rect(
+ canvas,
+ self.metrics.cell_width -| thickness_px,
+ 1,
+ self.metrics.cell_width,
+ self.metrics.cell_height,
+ );
+ // diagonal
+ self.draw_cell_diagonal(
+ canvas,
+ .lower_left,
+ .upper_right,
+ );
+ },
+
0x2800...0x28ff => self.draw_braille(canvas, cp),
0x1fb00...0x1fb3b => self.draw_sextant(canvas, cp),
@@ -588,35 +702,35 @@ fn draw(self: Box, alloc: Allocator, canvas: *font.sprite.Canvas, cp: u32) !void
octant_min...octant_max => self.draw_octant(canvas, cp),
// '🬼'
- 0x1fb3c => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from(
+ 0x1fb3c => try self.draw_smooth_mosaic(canvas, .from(
\\...
\\...
\\#..
\\##.
)),
// '🬽'
- 0x1fb3d => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from(
+ 0x1fb3d => try self.draw_smooth_mosaic(canvas, .from(
\\...
\\...
\\#\.
\\###
)),
// '🬾'
- 0x1fb3e => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from(
+ 0x1fb3e => try self.draw_smooth_mosaic(canvas, .from(
\\...
\\#..
\\#\.
\\##.
)),
// '🬿'
- 0x1fb3f => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from(
+ 0x1fb3f => try self.draw_smooth_mosaic(canvas, .from(
\\...
\\#..
\\##.
\\###
)),
// '🭀'
- 0x1fb40 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from(
+ 0x1fb40 => try self.draw_smooth_mosaic(canvas, .from(
\\#..
\\#..
\\##.
@@ -624,42 +738,42 @@ fn draw(self: Box, alloc: Allocator, canvas: *font.sprite.Canvas, cp: u32) !void
)),
// '🭁'
- 0x1fb41 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from(
+ 0x1fb41 => try self.draw_smooth_mosaic(canvas, .from(
\\/##
\\###
\\###
\\###
)),
// '🭂'
- 0x1fb42 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from(
+ 0x1fb42 => try self.draw_smooth_mosaic(canvas, .from(
\\./#
\\###
\\###
\\###
)),
// '🭃'
- 0x1fb43 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from(
+ 0x1fb43 => try self.draw_smooth_mosaic(canvas, .from(
\\.##
\\.##
\\###
\\###
)),
// '🭄'
- 0x1fb44 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from(
+ 0x1fb44 => try self.draw_smooth_mosaic(canvas, .from(
\\..#
\\.##
\\###
\\###
)),
// '🭅'
- 0x1fb45 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from(
+ 0x1fb45 => try self.draw_smooth_mosaic(canvas, .from(
\\.##
\\.##
\\.##
\\###
)),
// '🭆'
- 0x1fb46 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from(
+ 0x1fb46 => try self.draw_smooth_mosaic(canvas, .from(
\\...
\\./#
\\###
@@ -667,35 +781,35 @@ fn draw(self: Box, alloc: Allocator, canvas: *font.sprite.Canvas, cp: u32) !void
)),
// '🭇'
- 0x1fb47 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from(
+ 0x1fb47 => try self.draw_smooth_mosaic(canvas, .from(
\\...
\\...
\\..#
\\.##
)),
// '🭈'
- 0x1fb48 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from(
+ 0x1fb48 => try self.draw_smooth_mosaic(canvas, .from(
\\...
\\...
\\./#
\\###
)),
// '🭉'
- 0x1fb49 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from(
+ 0x1fb49 => try self.draw_smooth_mosaic(canvas, .from(
\\...
\\..#
\\./#
\\.##
)),
// '🭊'
- 0x1fb4a => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from(
+ 0x1fb4a => try self.draw_smooth_mosaic(canvas, .from(
\\...
\\..#
\\.##
\\###
)),
// '🭋'
- 0x1fb4b => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from(
+ 0x1fb4b => try self.draw_smooth_mosaic(canvas, .from(
\\..#
\\..#
\\.##
@@ -703,42 +817,42 @@ fn draw(self: Box, alloc: Allocator, canvas: *font.sprite.Canvas, cp: u32) !void
)),
// '🭌'
- 0x1fb4c => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from(
+ 0x1fb4c => try self.draw_smooth_mosaic(canvas, .from(
\\##\
\\###
\\###
\\###
)),
// '🭍'
- 0x1fb4d => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from(
+ 0x1fb4d => try self.draw_smooth_mosaic(canvas, .from(
\\#\.
\\###
\\###
\\###
)),
// '🭎'
- 0x1fb4e => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from(
+ 0x1fb4e => try self.draw_smooth_mosaic(canvas, .from(
\\##.
\\##.
\\###
\\###
)),
// '🭏'
- 0x1fb4f => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from(
+ 0x1fb4f => try self.draw_smooth_mosaic(canvas, .from(
\\#..
\\##.
\\###
\\###
)),
// '🭐'
- 0x1fb50 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from(
+ 0x1fb50 => try self.draw_smooth_mosaic(canvas, .from(
\\##.
\\##.
\\##.
\\###
)),
// '🭑'
- 0x1fb51 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from(
+ 0x1fb51 => try self.draw_smooth_mosaic(canvas, .from(
\\...
\\#\.
\\###
@@ -746,35 +860,35 @@ fn draw(self: Box, alloc: Allocator, canvas: *font.sprite.Canvas, cp: u32) !void
)),
// '🭒'
- 0x1fb52 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from(
+ 0x1fb52 => try self.draw_smooth_mosaic(canvas, .from(
\\###
\\###
\\###
\\\##
)),
// '🭓'
- 0x1fb53 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from(
+ 0x1fb53 => try self.draw_smooth_mosaic(canvas, .from(
\\###
\\###
\\###
\\.\#
)),
// '🭔'
- 0x1fb54 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from(
+ 0x1fb54 => try self.draw_smooth_mosaic(canvas, .from(
\\###
\\###
\\.##
\\.##
)),
// '🭕'
- 0x1fb55 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from(
+ 0x1fb55 => try self.draw_smooth_mosaic(canvas, .from(
\\###
\\###
\\.##
\\..#
)),
// '🭖'
- 0x1fb56 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from(
+ 0x1fb56 => try self.draw_smooth_mosaic(canvas, .from(
\\###
\\.##
\\.##
@@ -782,35 +896,35 @@ fn draw(self: Box, alloc: Allocator, canvas: *font.sprite.Canvas, cp: u32) !void
)),
// '🭗'
- 0x1fb57 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from(
+ 0x1fb57 => try self.draw_smooth_mosaic(canvas, .from(
\\##.
\\#..
\\...
\\...
)),
// '🭘'
- 0x1fb58 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from(
+ 0x1fb58 => try self.draw_smooth_mosaic(canvas, .from(
\\###
\\#/.
\\...
\\...
)),
// '🭙'
- 0x1fb59 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from(
+ 0x1fb59 => try self.draw_smooth_mosaic(canvas, .from(
\\##.
\\#/.
\\#..
\\...
)),
// '🭚'
- 0x1fb5a => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from(
+ 0x1fb5a => try self.draw_smooth_mosaic(canvas, .from(
\\###
\\##.
\\#..
\\...
)),
// '🭛'
- 0x1fb5b => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from(
+ 0x1fb5b => try self.draw_smooth_mosaic(canvas, .from(
\\##.
\\##.
\\#..
@@ -818,42 +932,42 @@ fn draw(self: Box, alloc: Allocator, canvas: *font.sprite.Canvas, cp: u32) !void
)),
// '🭜'
- 0x1fb5c => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from(
+ 0x1fb5c => try self.draw_smooth_mosaic(canvas, .from(
\\###
\\###
\\#/.
\\...
)),
// '🭝'
- 0x1fb5d => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from(
+ 0x1fb5d => try self.draw_smooth_mosaic(canvas, .from(
\\###
\\###
\\###
\\##/
)),
// '🭞'
- 0x1fb5e => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from(
+ 0x1fb5e => try self.draw_smooth_mosaic(canvas, .from(
\\###
\\###
\\###
\\#/.
)),
// '🭟'
- 0x1fb5f => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from(
+ 0x1fb5f => try self.draw_smooth_mosaic(canvas, .from(
\\###
\\###
\\##.
\\##.
)),
// '🭠'
- 0x1fb60 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from(
+ 0x1fb60 => try self.draw_smooth_mosaic(canvas, .from(
\\###
\\###
\\##.
\\#..
)),
// '🭡'
- 0x1fb61 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from(
+ 0x1fb61 => try self.draw_smooth_mosaic(canvas, .from(
\\###
\\##.
\\##.
@@ -861,42 +975,42 @@ fn draw(self: Box, alloc: Allocator, canvas: *font.sprite.Canvas, cp: u32) !void
)),
// '🭢'
- 0x1fb62 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from(
+ 0x1fb62 => try self.draw_smooth_mosaic(canvas, .from(
\\.##
\\..#
\\...
\\...
)),
// '🭣'
- 0x1fb63 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from(
+ 0x1fb63 => try self.draw_smooth_mosaic(canvas, .from(
\\###
\\.\#
\\...
\\...
)),
// '🭤'
- 0x1fb64 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from(
+ 0x1fb64 => try self.draw_smooth_mosaic(canvas, .from(
\\.##
\\.\#
\\..#
\\...
)),
// '🭥'
- 0x1fb65 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from(
+ 0x1fb65 => try self.draw_smooth_mosaic(canvas, .from(
\\###
\\.##
\\..#
\\...
)),
// '🭦'
- 0x1fb66 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from(
+ 0x1fb66 => try self.draw_smooth_mosaic(canvas, .from(
\\.##
\\.##
\\..#
\\..#
)),
// '🭧'
- 0x1fb67 => try self.draw_smooth_mosaic(canvas, SmoothMosaic.from(
+ 0x1fb67 => try self.draw_smooth_mosaic(canvas, .from(
\\###
\\###
\\.\#
@@ -959,79 +1073,79 @@ fn draw(self: Box, alloc: Allocator, canvas: *font.sprite.Canvas, cp: u32) !void
0x1fb7b => self.draw_horizontal_one_eighth_block_n(canvas, 6),
// '🮂' UPPER ONE QUARTER BLOCK
- 0x1fb82 => self.draw_block(canvas, Alignment.upper, 1, one_quarter),
+ 0x1fb82 => self.draw_block(canvas, .upper, 1, one_quarter),
// '🮃' UPPER THREE EIGHTHS BLOCK
- 0x1fb83 => self.draw_block(canvas, Alignment.upper, 1, three_eighths),
+ 0x1fb83 => self.draw_block(canvas, .upper, 1, three_eighths),
// '🮄' UPPER FIVE EIGHTHS BLOCK
- 0x1fb84 => self.draw_block(canvas, Alignment.upper, 1, five_eighths),
+ 0x1fb84 => self.draw_block(canvas, .upper, 1, five_eighths),
// '🮅' UPPER THREE QUARTERS BLOCK
- 0x1fb85 => self.draw_block(canvas, Alignment.upper, 1, three_quarters),
+ 0x1fb85 => self.draw_block(canvas, .upper, 1, three_quarters),
// '🮆' UPPER SEVEN EIGHTHS BLOCK
- 0x1fb86 => self.draw_block(canvas, Alignment.upper, 1, seven_eighths),
+ 0x1fb86 => self.draw_block(canvas, .upper, 1, seven_eighths),
// '🭼' LEFT AND LOWER ONE EIGHTH BLOCK
0x1fb7c => {
- self.draw_block(canvas, Alignment.left, one_eighth, 1);
- self.draw_block(canvas, Alignment.lower, 1, one_eighth);
+ self.draw_block(canvas, .left, one_eighth, 1);
+ self.draw_block(canvas, .lower, 1, one_eighth);
},
// '🭽' LEFT AND UPPER ONE EIGHTH BLOCK
0x1fb7d => {
- self.draw_block(canvas, Alignment.left, one_eighth, 1);
- self.draw_block(canvas, Alignment.upper, 1, one_eighth);
+ self.draw_block(canvas, .left, one_eighth, 1);
+ self.draw_block(canvas, .upper, 1, one_eighth);
},
// '🭾' RIGHT AND UPPER ONE EIGHTH BLOCK
0x1fb7e => {
- self.draw_block(canvas, Alignment.right, one_eighth, 1);
- self.draw_block(canvas, Alignment.upper, 1, one_eighth);
+ self.draw_block(canvas, .right, one_eighth, 1);
+ self.draw_block(canvas, .upper, 1, one_eighth);
},
// '🭿' RIGHT AND LOWER ONE EIGHTH BLOCK
0x1fb7f => {
- self.draw_block(canvas, Alignment.right, one_eighth, 1);
- self.draw_block(canvas, Alignment.lower, 1, one_eighth);
+ self.draw_block(canvas, .right, one_eighth, 1);
+ self.draw_block(canvas, .lower, 1, one_eighth);
},
// '🮀' UPPER AND LOWER ONE EIGHTH BLOCK
0x1fb80 => {
- self.draw_block(canvas, Alignment.upper, 1, one_eighth);
- self.draw_block(canvas, Alignment.lower, 1, one_eighth);
+ self.draw_block(canvas, .upper, 1, one_eighth);
+ self.draw_block(canvas, .lower, 1, one_eighth);
},
// '🮁'
0x1fb81 => self.draw_horizontal_one_eighth_1358_block(canvas),
// '🮇' RIGHT ONE QUARTER BLOCK
- 0x1fb87 => self.draw_block(canvas, Alignment.right, one_quarter, 1),
+ 0x1fb87 => self.draw_block(canvas, .right, one_quarter, 1),
// '🮈' RIGHT THREE EIGHTHS BLOCK
- 0x1fb88 => self.draw_block(canvas, Alignment.right, three_eighths, 1),
+ 0x1fb88 => self.draw_block(canvas, .right, three_eighths, 1),
// '🮉' RIGHT FIVE EIGHTHS BLOCK
- 0x1fb89 => self.draw_block(canvas, Alignment.right, five_eighths, 1),
+ 0x1fb89 => self.draw_block(canvas, .right, five_eighths, 1),
// '🮊' RIGHT THREE QUARTERS BLOCK
- 0x1fb8a => self.draw_block(canvas, Alignment.right, three_quarters, 1),
+ 0x1fb8a => self.draw_block(canvas, .right, three_quarters, 1),
// '🮋' RIGHT SEVEN EIGHTHS BLOCK
- 0x1fb8b => self.draw_block(canvas, Alignment.right, seven_eighths, 1),
+ 0x1fb8b => self.draw_block(canvas, .right, seven_eighths, 1),
// '🮌'
- 0x1fb8c => self.draw_block_shade(canvas, Alignment.left, half, 1, .medium),
+ 0x1fb8c => self.draw_block_shade(canvas, .left, half, 1, .medium),
// '🮍'
- 0x1fb8d => self.draw_block_shade(canvas, Alignment.right, half, 1, .medium),
+ 0x1fb8d => self.draw_block_shade(canvas, .right, half, 1, .medium),
// '🮎'
- 0x1fb8e => self.draw_block_shade(canvas, Alignment.upper, 1, half, .medium),
+ 0x1fb8e => self.draw_block_shade(canvas, .upper, 1, half, .medium),
// '🮏'
- 0x1fb8f => self.draw_block_shade(canvas, Alignment.lower, 1, half, .medium),
+ 0x1fb8f => self.draw_block_shade(canvas, .lower, 1, half, .medium),
// '🮐'
0x1fb90 => self.draw_medium_shade(canvas),
// '🮑'
0x1fb91 => {
self.draw_medium_shade(canvas);
- self.draw_block(canvas, Alignment.upper, 1, half);
+ self.draw_block(canvas, .upper, 1, half);
},
// '🮒'
0x1fb92 => {
self.draw_medium_shade(canvas);
- self.draw_block(canvas, Alignment.lower, 1, half);
+ self.draw_block(canvas, .lower, 1, half);
},
// '🮔'
0x1fb94 => {
self.draw_medium_shade(canvas);
- self.draw_block(canvas, Alignment.right, half, 1);
+ self.draw_block(canvas, .right, half, 1);
},
// '🮕'
0x1fb95 => self.draw_checkerboard_fill(canvas, 0),
@@ -1117,194 +1231,194 @@ fn draw(self: Box, alloc: Allocator, canvas: *font.sprite.Canvas, cp: u32) !void
},
// '🯎'
- 0x1fbce => self.draw_block(canvas, Alignment.left, two_thirds, 1),
+ 0x1fbce => self.draw_block(canvas, .left, two_thirds, 1),
// '🯏'
- 0x1fbcf => self.draw_block(canvas, Alignment.left, one_third, 1),
+ 0x1fbcf => self.draw_block(canvas, .left, one_third, 1),
// '🯐'
0x1fbd0 => self.draw_cell_diagonal(
canvas,
- Alignment.middle_right,
- Alignment.lower_left,
+ .middle_right,
+ .lower_left,
),
// '🯑'
0x1fbd1 => self.draw_cell_diagonal(
canvas,
- Alignment.upper_right,
- Alignment.middle_left,
+ .upper_right,
+ .middle_left,
),
// '🯒'
0x1fbd2 => self.draw_cell_diagonal(
canvas,
- Alignment.upper_left,
- Alignment.middle_right,
+ .upper_left,
+ .middle_right,
),
// '🯓'
0x1fbd3 => self.draw_cell_diagonal(
canvas,
- Alignment.middle_left,
- Alignment.lower_right,
+ .middle_left,
+ .lower_right,
),
// '🯔'
0x1fbd4 => self.draw_cell_diagonal(
canvas,
- Alignment.upper_left,
- Alignment.lower_center,
+ .upper_left,
+ .lower_center,
),
// '🯕'
0x1fbd5 => self.draw_cell_diagonal(
canvas,
- Alignment.upper_center,
- Alignment.lower_right,
+ .upper_center,
+ .lower_right,
),
// '🯖'
0x1fbd6 => self.draw_cell_diagonal(
canvas,
- Alignment.upper_right,
- Alignment.lower_center,
+ .upper_right,
+ .lower_center,
),
// '🯗'
0x1fbd7 => self.draw_cell_diagonal(
canvas,
- Alignment.upper_center,
- Alignment.lower_left,
+ .upper_center,
+ .lower_left,
),
// '🯘'
0x1fbd8 => {
self.draw_cell_diagonal(
canvas,
- Alignment.upper_left,
- Alignment.middle_center,
+ .upper_left,
+ .middle_center,
);
self.draw_cell_diagonal(
canvas,
- Alignment.middle_center,
- Alignment.upper_right,
+ .middle_center,
+ .upper_right,
);
},
// '🯙'
0x1fbd9 => {
self.draw_cell_diagonal(
canvas,
- Alignment.upper_right,
- Alignment.middle_center,
+ .upper_right,
+ .middle_center,
);
self.draw_cell_diagonal(
canvas,
- Alignment.middle_center,
- Alignment.lower_right,
+ .middle_center,
+ .lower_right,
);
},
// '🯚'
0x1fbda => {
self.draw_cell_diagonal(
canvas,
- Alignment.lower_left,
- Alignment.middle_center,
+ .lower_left,
+ .middle_center,
);
self.draw_cell_diagonal(
canvas,
- Alignment.middle_center,
- Alignment.lower_right,
+ .middle_center,
+ .lower_right,
);
},
// '🯛'
0x1fbdb => {
self.draw_cell_diagonal(
canvas,
- Alignment.upper_left,
- Alignment.middle_center,
+ .upper_left,
+ .middle_center,
);
self.draw_cell_diagonal(
canvas,
- Alignment.middle_center,
- Alignment.lower_left,
+ .middle_center,
+ .lower_left,
);
},
// '🯜'
0x1fbdc => {
self.draw_cell_diagonal(
canvas,
- Alignment.upper_left,
- Alignment.lower_center,
+ .upper_left,
+ .lower_center,
);
self.draw_cell_diagonal(
canvas,
- Alignment.lower_center,
- Alignment.upper_right,
+ .lower_center,
+ .upper_right,
);
},
// '🯝'
0x1fbdd => {
self.draw_cell_diagonal(
canvas,
- Alignment.upper_right,
- Alignment.middle_left,
+ .upper_right,
+ .middle_left,
);
self.draw_cell_diagonal(
canvas,
- Alignment.middle_left,
- Alignment.lower_right,
+ .middle_left,
+ .lower_right,
);
},
// '🯞'
0x1fbde => {
self.draw_cell_diagonal(
canvas,
- Alignment.lower_left,
- Alignment.upper_center,
+ .lower_left,
+ .upper_center,
);
self.draw_cell_diagonal(
canvas,
- Alignment.upper_center,
- Alignment.lower_right,
+ .upper_center,
+ .lower_right,
);
},
// '🯟'
0x1fbdf => {
self.draw_cell_diagonal(
canvas,
- Alignment.upper_left,
- Alignment.middle_right,
+ .upper_left,
+ .middle_right,
);
self.draw_cell_diagonal(
canvas,
- Alignment.middle_right,
- Alignment.lower_left,
+ .middle_right,
+ .lower_left,
);
},
// '🯠'
- 0x1fbe0 => self.draw_circle(canvas, Alignment.top, false),
+ 0x1fbe0 => self.draw_circle(canvas, .top, false),
// '🯡'
- 0x1fbe1 => self.draw_circle(canvas, Alignment.right, false),
+ 0x1fbe1 => self.draw_circle(canvas, .right, false),
// '🯢'
- 0x1fbe2 => self.draw_circle(canvas, Alignment.bottom, false),
+ 0x1fbe2 => self.draw_circle(canvas, .bottom, false),
// '🯣'
- 0x1fbe3 => self.draw_circle(canvas, Alignment.left, false),
+ 0x1fbe3 => self.draw_circle(canvas, .left, false),
// '🯤'
- 0x1fbe4 => self.draw_block(canvas, Alignment.upper_center, 0.5, 0.5),
+ 0x1fbe4 => self.draw_block(canvas, .upper_center, 0.5, 0.5),
// '🯥'
- 0x1fbe5 => self.draw_block(canvas, Alignment.lower_center, 0.5, 0.5),
+ 0x1fbe5 => self.draw_block(canvas, .lower_center, 0.5, 0.5),
// '🯦'
- 0x1fbe6 => self.draw_block(canvas, Alignment.middle_left, 0.5, 0.5),
+ 0x1fbe6 => self.draw_block(canvas, .middle_left, 0.5, 0.5),
// '🯧'
- 0x1fbe7 => self.draw_block(canvas, Alignment.middle_right, 0.5, 0.5),
+ 0x1fbe7 => self.draw_block(canvas, .middle_right, 0.5, 0.5),
// '🯨'
- 0x1fbe8 => self.draw_circle(canvas, Alignment.top, true),
+ 0x1fbe8 => self.draw_circle(canvas, .top, true),
// '🯩'
- 0x1fbe9 => self.draw_circle(canvas, Alignment.right, true),
+ 0x1fbe9 => self.draw_circle(canvas, .right, true),
// '🯪'
- 0x1fbea => self.draw_circle(canvas, Alignment.bottom, true),
+ 0x1fbea => self.draw_circle(canvas, .bottom, true),
// '🯫'
- 0x1fbeb => self.draw_circle(canvas, Alignment.left, true),
+ 0x1fbeb => self.draw_circle(canvas, .left, true),
// '🯬'
- 0x1fbec => self.draw_circle(canvas, Alignment.top_right, true),
+ 0x1fbec => self.draw_circle(canvas, .top_right, true),
// '🯭'
- 0x1fbed => self.draw_circle(canvas, Alignment.bottom_left, true),
+ 0x1fbed => self.draw_circle(canvas, .bottom_left, true),
// '🯮'
- 0x1fbee => self.draw_circle(canvas, Alignment.bottom_right, true),
+ 0x1fbee => self.draw_circle(canvas, .bottom_right, true),
// '🯯'
- 0x1fbef => self.draw_circle(canvas, Alignment.top_left, true),
+ 0x1fbef => self.draw_circle(canvas, .top_left, true),
// (Below:)
// Branch drawing character set, used for drawing git-like
@@ -2488,10 +2602,10 @@ fn draw_sextant(self: Box, canvas: *font.sprite.Canvas, cp: u32) void {
if (sex.tl) self.rect(canvas, 0, 0, x_halfs[0], y_thirds[0]);
if (sex.tr) self.rect(canvas, x_halfs[1], 0, self.metrics.cell_width, y_thirds[0]);
- if (sex.ml) self.rect(canvas, 0, y_thirds[0], x_halfs[0], y_thirds[1]);
- if (sex.mr) self.rect(canvas, x_halfs[1], y_thirds[0], self.metrics.cell_width, y_thirds[1]);
- if (sex.bl) self.rect(canvas, 0, y_thirds[1], x_halfs[0], self.metrics.cell_height);
- if (sex.br) self.rect(canvas, x_halfs[1], y_thirds[1], self.metrics.cell_width, self.metrics.cell_height);
+ if (sex.ml) self.rect(canvas, 0, y_thirds[1], x_halfs[0], y_thirds[2]);
+ if (sex.mr) self.rect(canvas, x_halfs[1], y_thirds[1], self.metrics.cell_width, y_thirds[2]);
+ if (sex.bl) self.rect(canvas, 0, y_thirds[3], x_halfs[0], self.metrics.cell_height);
+ if (sex.br) self.rect(canvas, x_halfs[1], y_thirds[3], self.metrics.cell_width, self.metrics.cell_height);
}
fn draw_octant(self: Box, canvas: *font.sprite.Canvas, cp: u32) void {
@@ -2517,7 +2631,7 @@ fn draw_octant(self: Box, canvas: *font.sprite.Canvas, cp: u32) void {
const octants: [octants_len]Octant = comptime octants: {
@setEvalBranchQuota(10_000);
- var result: [octants_len]Octant = .{Octant{}} ** octants_len;
+ var result: [octants_len]Octant = @splat(.{});
var i: usize = 0;
const data = @embedFile("octants.txt");
@@ -2545,42 +2659,58 @@ fn draw_octant(self: Box, canvas: *font.sprite.Canvas, cp: u32) void {
const oct = octants[cp - octant_min];
if (oct.@"1") self.rect(canvas, 0, 0, x_halfs[0], y_quads[0]);
if (oct.@"2") self.rect(canvas, x_halfs[1], 0, self.metrics.cell_width, y_quads[0]);
- if (oct.@"3") self.rect(canvas, 0, y_quads[0], x_halfs[0], y_quads[1]);
- if (oct.@"4") self.rect(canvas, x_halfs[1], y_quads[0], self.metrics.cell_width, y_quads[1]);
- if (oct.@"5") self.rect(canvas, 0, y_quads[1], x_halfs[0], y_quads[2]);
- if (oct.@"6") self.rect(canvas, x_halfs[1], y_quads[1], self.metrics.cell_width, y_quads[2]);
- if (oct.@"7") self.rect(canvas, 0, y_quads[2], x_halfs[0], self.metrics.cell_height);
- if (oct.@"8") self.rect(canvas, x_halfs[1], y_quads[2], self.metrics.cell_width, self.metrics.cell_height);
+ if (oct.@"3") self.rect(canvas, 0, y_quads[1], x_halfs[0], y_quads[2]);
+ if (oct.@"4") self.rect(canvas, x_halfs[1], y_quads[1], self.metrics.cell_width, y_quads[2]);
+ if (oct.@"5") self.rect(canvas, 0, y_quads[3], x_halfs[0], y_quads[4]);
+ if (oct.@"6") self.rect(canvas, x_halfs[1], y_quads[3], self.metrics.cell_width, y_quads[4]);
+ if (oct.@"7") self.rect(canvas, 0, y_quads[5], x_halfs[0], self.metrics.cell_height);
+ if (oct.@"8") self.rect(canvas, x_halfs[1], y_quads[5], self.metrics.cell_width, self.metrics.cell_height);
}
+/// xHalfs[0] should be used as the right edge of a left-aligned half.
+/// xHalfs[1] should be used as the left edge of a right-aligned half.
fn xHalfs(self: Box) [2]u32 {
- return .{
- @as(u32, @intFromFloat(@round(@as(f64, @floatFromInt(self.metrics.cell_width)) / 2))),
- @as(u32, @intFromFloat(@as(f64, @floatFromInt(self.metrics.cell_width)) / 2)),
- };
+ const float_width: f64 = @floatFromInt(self.metrics.cell_width);
+ const half_width: u32 = @intFromFloat(@round(0.5 * float_width));
+ return .{ half_width, self.metrics.cell_width - half_width };
}
-fn yThirds(self: Box) [2]u32 {
- return switch (@mod(self.metrics.cell_height, 3)) {
- 0 => .{ self.metrics.cell_height / 3, 2 * self.metrics.cell_height / 3 },
- 1 => .{ self.metrics.cell_height / 3, 2 * self.metrics.cell_height / 3 + 1 },
- 2 => .{ self.metrics.cell_height / 3 + 1, 2 * self.metrics.cell_height / 3 },
- else => unreachable,
+/// Use these values as such:
+/// yThirds[0] bottom edge of the first third.
+/// yThirds[1] top edge of the second third.
+/// yThirds[2] bottom edge of the second third.
+/// yThirds[3] top edge of the final third.
+fn yThirds(self: Box) [4]u32 {
+ const float_height: f64 = @floatFromInt(self.metrics.cell_height);
+ const one_third_height: u32 = @intFromFloat(@round(one_third * float_height));
+ const two_thirds_height: u32 = @intFromFloat(@round(two_thirds * float_height));
+ return .{
+ one_third_height,
+ self.metrics.cell_height - two_thirds_height,
+ two_thirds_height,
+ self.metrics.cell_height - one_third_height,
};
}
-// assume octants might be striped across multiple rows of cells. to maximize
-// distance between excess pixellines, we want (1) an arbitrary region (there
-// will be a pattern of 1'-3-1'-3-1'-3 no matter what), (2) discontiguous
-// regions (0 and 2 or 1 and 3), and (3) an arbitrary three regions (there will
-// be a pattern of 3-1-3-1-3-1 no matter what).
-fn yQuads(self: Box) [3]u32 {
- return switch (@mod(self.metrics.cell_height, 4)) {
- 0 => .{ self.metrics.cell_height / 4, 2 * self.metrics.cell_height / 4, 3 * self.metrics.cell_height / 4 },
- 1 => .{ self.metrics.cell_height / 4, 2 * self.metrics.cell_height / 4 + 1, 3 * self.metrics.cell_height / 4 },
- 2 => .{ self.metrics.cell_height / 4 + 1, 2 * self.metrics.cell_height / 4, 3 * self.metrics.cell_height / 4 + 1 },
- 3 => .{ self.metrics.cell_height / 4 + 1, 2 * self.metrics.cell_height / 4 + 1, 3 * self.metrics.cell_height / 4 },
- else => unreachable,
+/// Use these values as such:
+/// yQuads[0] bottom edge of first quarter.
+/// yQuads[1] top edge of second quarter.
+/// yQuads[2] bottom edge of second quarter.
+/// yQuads[3] top edge of third quarter.
+/// yQuads[4] bottom edge of third quarter
+/// yQuads[5] top edge of fourth quarter.
+fn yQuads(self: Box) [6]u32 {
+ const float_height: f64 = @floatFromInt(self.metrics.cell_height);
+ const quarter_height: u32 = @intFromFloat(@round(0.25 * float_height));
+ const half_height: u32 = @intFromFloat(@round(0.50 * float_height));
+ const three_quarters_height: u32 = @intFromFloat(@round(0.75 * float_height));
+ return .{
+ quarter_height,
+ self.metrics.cell_height - three_quarters_height,
+ half_height,
+ self.metrics.cell_height - half_height,
+ three_quarters_height,
+ self.metrics.cell_height - quarter_height,
};
}
@@ -2591,8 +2721,12 @@ fn draw_smooth_mosaic(
) !void {
const y_thirds = self.yThirds();
const top: f64 = 0.0;
- const upper: f64 = @floatFromInt(y_thirds[0]);
- const lower: f64 = @floatFromInt(y_thirds[1]);
+ // We average the edge positions for the y_thirds boundaries here
+ // rather than having to deal with varying alignments depending on
+ // the surrounding pieces. The most this will be off by is half of
+ // a pixel, so hopefully it's not noticeable.
+ const upper: f64 = 0.5 * (@as(f64, @floatFromInt(y_thirds[0])) + @as(f64, @floatFromInt(y_thirds[1])));
+ const lower: f64 = 0.5 * (@as(f64, @floatFromInt(y_thirds[2])) + @as(f64, @floatFromInt(y_thirds[3])));
const bottom: f64 = @floatFromInt(self.metrics.cell_height);
const left: f64 = 0.0;
const center: f64 = @round(@as(f64, @floatFromInt(self.metrics.cell_width)) / 2);
@@ -3177,6 +3311,15 @@ fn testRenderAll(self: Box, alloc: Allocator, atlas: *font.Atlas) !void {
else => {},
}
}
+
+ // Geometric Shapes: filled and outlined corners
+ for ([_]u21{ '◢', '◣', '◤', '◥', '◸', '◹', '◺', '◿' }) |char| {
+ _ = try self.renderGlyph(
+ alloc,
+ atlas,
+ char,
+ );
+ }
}
test "render all sprites" {
diff --git a/src/font/sprite/Face.zig b/src/font/sprite/Face.zig
index f15423ada..af0c0af6a 100644
--- a/src/font/sprite/Face.zig
+++ b/src/font/sprite/Face.zig
@@ -190,6 +190,11 @@ const Kind = enum {
// ▀ ▁ ▂ ▃ ▄ ▅ ▆ ▇ █ ▉ ▊ ▋ ▌ ▍ ▎ ▏ ▐ ░ ▒ ▓ ▔ ▕ ▖ ▗ ▘ ▙ ▚ ▛ ▜ ▝ ▞ ▟
0x2580...0x259F,
+ // "Geometric Shapes" block
+ 0x25e2...0x25e5, // ◢◣◤◥
+ 0x25f8...0x25fa, // ◸◹◺
+ 0x25ff, // ◿
+
// "Braille" block
0x2800...0x28FF,
diff --git a/src/font/sprite/canvas.zig b/src/font/sprite/canvas.zig
index ed00aef12..a5ca7b290 100644
--- a/src/font/sprite/canvas.zig
+++ b/src/font/sprite/canvas.zig
@@ -150,7 +150,7 @@ pub const Canvas = struct {
/// Acquires a z2d drawing context, caller MUST deinit context.
pub fn getContext(self: *Canvas) z2d.Context {
- return z2d.Context.init(self.alloc, &self.sfc);
+ return .init(self.alloc, &self.sfc);
}
/// Draw and fill a single pixel
diff --git a/src/font/sprite/cursor.zig b/src/font/sprite/cursor.zig
index 62195316e..d63db624a 100644
--- a/src/font/sprite/cursor.zig
+++ b/src/font/sprite/cursor.zig
@@ -50,7 +50,11 @@ pub fn renderGlyph(
const region = try canvas.writeAtlas(alloc, atlas);
return font.Glyph{
- .width = width,
+ // HACK: Set the width for the bar cursor to just the thickness,
+ // this is just for the benefit of the custom shader cursor
+ // uniform code. -- In the future code will be introduced to
+ // auto-crop the canvas so that this isn't needed.
+ .width = if (sprite == .cursor_bar) thickness else width,
.height = height,
.offset_x = 0,
.offset_y = @intCast(height),
diff --git a/src/font/sprite/testdata/Box.ppm b/src/font/sprite/testdata/Box.ppm
index 0feb3ebe4..6082475af 100644
--- a/src/font/sprite/testdata/Box.ppm
+++ b/src/font/sprite/testdata/Box.ppm
Binary files differ
diff --git a/src/global.zig b/src/global.zig
index 375c10538..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();
@@ -139,7 +140,7 @@ pub const GlobalState = struct {
std.log.info("libxev default backend={s}", .{@tagName(xev.backend)});
// As early as possible, initialize our resource limits.
- self.rlimits = ResourceLimits.init();
+ self.rlimits = .init();
// Initialize our crash reporting.
crash.init(self.alloc) catch |err| {
@@ -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 59adc7149..7cdb8047c 100644
--- a/src/input/Binding.zig
+++ b/src/input/Binding.zig
@@ -63,15 +63,17 @@ pub const Parser = struct {
const flags, const start_idx = try parseFlags(raw_input);
const input = raw_input[start_idx..];
- // Find the first = which splits are mapping into the trigger
+ // Find the last = which splits are mapping into the trigger
// and action, respectively.
- const eql_idx = std.mem.indexOf(u8, input, "=") orelse return Error.InvalidFormat;
+ // We use the last = because the keybind itself could contain
+ // raw equal signs (for the = codepoint)
+ const eql_idx = std.mem.lastIndexOf(u8, input, "=") orelse return Error.InvalidFormat;
// Sequence iterator goes up to the equal, action is after. We can
// parse the action now.
return .{
.trigger_it = .{ .input = input[0..eql_idx] },
- .action = try Action.parse(input[eql_idx + 1 ..]),
+ .action = try .parse(input[eql_idx + 1 ..]),
.flags = flags,
};
}
@@ -158,7 +160,7 @@ const SequenceIterator = struct {
const rem = self.input[self.i..];
const idx = std.mem.indexOf(u8, rem, ">") orelse rem.len;
defer self.i += idx + 1;
- return try Trigger.parse(rem[0..idx]);
+ return try .parse(rem[0..idx]);
}
/// Returns true if there are no more triggers to parse.
@@ -222,107 +224,195 @@ pub fn lessThan(_: void, lhs: Binding, rhs: Binding) bool {
/// The set of actions that a keybinding can take.
pub const Action = union(enum) {
- /// Ignore this key combination, don't send it to the child process, just
- /// black hole it.
+ /// Ignore this key combination.
+ ///
+ /// Ghostty will not process this combination nor forward it to the child
+ /// process within the terminal, but it may still be processed by the OS or
+ /// other applications.
ignore,
- /// This action is used to flag that the binding should be removed from
- /// the set. This should never exist in an active set and `set.put` has an
- /// assertion to verify this.
+ /// Unbind a previously bound key binding.
+ ///
+ /// This cannot unbind bindings that were not bound by Ghostty or the user
+ /// (e.g. bindings set by the OS or some other application).
unbind,
- /// Send a CSI sequence. The value should be the CSI sequence without the
- /// CSI header (`ESC [` or `\x1b[`).
+ /// Send a CSI sequence.
+ ///
+ /// The value should be the CSI sequence without the CSI header (`ESC [` or
+ /// `\x1b[`).
+ ///
+ /// For example, `csi:0m` can be sent to reset all styles of the current text.
csi: []const u8,
/// Send an `ESC` sequence.
esc: []const u8,
- /// Send the given text. Uses Zig string literal syntax. This is currently
- /// not validated. If the text is invalid (i.e. contains an invalid escape
- /// sequence), the error will currently only show up in logs.
+ /// Send the specified text.
+ ///
+ /// Uses Zig string literal syntax. This is currently not validated.
+ /// If the text is invalid (i.e. contains an invalid escape sequence),
+ /// the error will currently only show up in logs.
text: []const u8,
/// Send data to the pty depending on whether cursor key mode is enabled
/// (`application`) or disabled (`normal`).
cursor_key: CursorKey,
- /// Reset the terminal. This can fix a lot of issues when a running
- /// program puts the terminal into a broken state. This is equivalent to
- /// when you type "reset" and press enter.
+ /// Reset the terminal.
+ ///
+ /// This can fix a lot of issues when a running program puts the terminal
+ /// into a broken state, equivalent to running the `reset` command.
///
/// If you do this while in a TUI program such as vim, this may break
/// the program. If you do this while in a shell, you may have to press
/// enter after to get a new prompt.
reset,
- /// Copy and paste.
+ /// Copy the selected text to the clipboard.
copy_to_clipboard,
+
+ /// Paste the contents of the default clipboard.
paste_from_clipboard,
+
+ /// Paste the contents of the selection clipboard.
paste_from_selection,
- /// Copy the URL under the cursor to the clipboard. If there is no
- /// URL under the cursor, this does nothing.
+ /// If there is a URL under the cursor, copy it to the default clipboard.
copy_url_to_clipboard,
- /// Increase/decrease the font size by a certain amount.
+ /// Increase the font size by the specified amount in points (pt).
+ ///
+ /// For example, `increase_font_size:1.5` will increase the font size
+ /// by 1.5 points.
increase_font_size: f32,
+
+ /// Decrease the font size by the specified amount in points (pt).
+ ///
+ /// For example, `decrease_font_size:1.5` will decrease the font size
+ /// by 1.5 points.
decrease_font_size: f32,
/// Reset the font size to the original configured size.
reset_font_size,
- /// Clear the screen. This also clears all scrollback.
+ /// Clear the screen and all scrollback.
clear_screen,
/// Select all text on the screen.
select_all,
- /// Scroll the screen varying amounts.
+ /// Scroll to the top of the screen.
scroll_to_top,
+
+ /// Scroll to the bottom of the screen.
scroll_to_bottom,
+
+ /// Scroll to the selected text.
scroll_to_selection,
+
+ /// Scroll the screen up by one page.
scroll_page_up,
+
+ /// Scroll the screen down by one page.
scroll_page_down,
+
+ /// Scroll the screen by the specified fraction of a page.
+ ///
+ /// Positive values scroll downwards, and negative values scroll upwards.
+ ///
+ /// For example, `scroll_page_fractional:0.5` would scroll the screen
+ /// downwards by half a page, while `scroll_page_fractional:-1.5` would
+ /// scroll it upwards by one and a half pages.
scroll_page_fractional: f32,
+
+ /// Scroll the screen by the specified amount of lines.
+ ///
+ /// Positive values scroll downwards, and negative values scroll upwards.
+ ///
+ /// For example, `scroll_page_lines:3` would scroll the screen downwards
+ /// by 3 lines, while `scroll_page_lines:-10` would scroll it upwards by 10
+ /// lines.
scroll_page_lines: i16,
- /// Adjust the current selection in a given direction. Does nothing if no
- /// selection exists.
+ /// Adjust the current selection in the given direction or position,
+ /// relative to the cursor.
+ ///
+ /// WARNING: This does not create a new selection, and does nothing when
+ /// there currently isn't one.
+ ///
+ /// Valid arguments are:
+ ///
+ /// - `left`, `right`
+ ///
+ /// Adjust the selection one cell to the left or right respectively.
+ ///
+ /// - `up`, `down`
+ ///
+ /// Adjust the selection one line upwards or downwards respectively.
+ ///
+ /// - `page_up`, `page_down`
///
- /// Arguments:
- /// - left, right, up, down, page_up, page_down, home, end,
- /// beginning_of_line, end_of_line
+ /// Adjust the selection one page upwards or downwards respectively.
+ ///
+ /// - `home`, `end`
+ ///
+ /// Adjust the selection to the top-left or the bottom-right corner
+ /// of the screen respectively.
+ ///
+ /// - `beginning_of_line`, `end_of_line`
+ ///
+ /// Adjust the selection to the beginning or the end of the line
+ /// respectively.
///
- /// Example: Extend selection to the right
- /// keybind = shift+right=adjust_selection:right
adjust_selection: AdjustSelection,
- /// Jump the viewport forward or back by prompt. Positive number is the
- /// number of prompts to jump forward, negative is backwards.
+ /// Jump the viewport forward or back by the given number of prompts.
+ ///
+ /// Requires shell integration.
+ ///
+ /// Positive values scroll downwards, and negative values scroll upwards.
jump_to_prompt: i16,
- /// Write the entire scrollback into a temporary file. The action
- /// determines what to do with the filepath. Valid values are:
+ /// Write the entire scrollback into a temporary file with the specified
+ /// action. The action determines what to do with the filepath.
+ ///
+ /// Valid actions are:
+ ///
+ /// - `copy`
+ ///
+ /// Copy the file path into the clipboard.
+ ///
+ /// - `paste`
+ ///
+ /// Paste the file path into the terminal.
+ ///
+ /// - `open`
+ ///
+ /// Open the file in the default OS editor for text files.
///
- /// - "paste": Paste the file path into the terminal.
- /// - "open": Open the file in the default OS editor for text files.
/// The default OS editor is determined by using `open` on macOS
/// and `xdg-open` on Linux.
///
write_scrollback_file: WriteScreenAction,
- /// Same as write_scrollback_file but writes the full screen contents.
- /// See write_scrollback_file for available values.
+ /// Write the contents of the screen into a temporary file with the
+ /// specified action.
+ ///
+ /// See `write_scrollback_file` for possible actions.
write_screen_file: WriteScreenAction,
- /// Same as write_scrollback_file but writes the selected text.
- /// If there is no selected text this does nothing (it doesn't
- /// even create an empty file). See write_scrollback_file for
- /// available values.
+ /// Write the currently selected text into a temporary file with the
+ /// specified action.
+ ///
+ /// See `write_scrollback_file` for possible actions.
+ ///
+ /// Does nothing when no text is selected.
write_selection_file: WriteScreenAction,
- /// Open a new window. If the application isn't currently focused,
+ /// Open a new window.
+ ///
+ /// If the application isn't currently focused,
/// this will bring it to the front.
new_window,
@@ -335,187 +425,275 @@ pub const Action = union(enum) {
/// Go to the next tab.
next_tab,
- /// Go to the last tab (the one with the highest index)
+ /// Go to the last tab.
last_tab,
- /// Go to the tab with the specific number, 1-indexed. If the tab number
- /// is higher than the number of tabs, this will go to the last tab.
+ /// Go to the tab with the specific index, starting from 1.
+ ///
+ /// If the tab number is higher than the number of tabs,
+ /// this will go to the last tab.
goto_tab: usize,
/// Moves a tab by a relative offset.
- /// Adjusts the tab position based on `offset`. For example `move_tab:-1` for left, `move_tab:1` for right.
- /// If the new position is out of bounds, it wraps around cyclically within the tab range.
+ ///
+ /// Positive values move the tab forwards, and negative values move it
+ /// backwards. If the new position is out of bounds, it is wrapped around
+ /// cyclically within the tab list.
+ ///
+ /// For example, `move_tab:1` moves the tab one position forwards, and if
+ /// it was already the last tab in the list, it wraps around and becomes
+ /// the first tab in the list. Likewise, `move_tab:-1` moves the tab one
+ /// position backwards, and if it was the first tab, then it will become
+ /// the last tab.
move_tab: isize,
/// Toggle the tab overview.
- /// This only works with libadwaita version 1.4.0 or newer.
+ ///
+ /// This is only supported on Linux and when the system's libadwaita
+ /// version is 1.4 or newer. The current libadwaita version can be
+ /// found by running `ghostty +version`.
toggle_tab_overview,
- /// Change the title of the current focused surface via a prompt.
+ /// Change the title of the current focused surface via a pop-up prompt.
+ ///
+ /// This requires libadwaita 1.5 or newer on Linux. The current libadwaita
+ /// version can be found by running `ghostty +version`.
prompt_surface_title,
- /// Create a new split in the given direction.
+ /// Create a new split in the specified direction.
+ ///
+ /// Valid arguments:
+ ///
+ /// - `right`, `down`, `left`, `up`
+ ///
+ /// Creates a new split in the corresponding direction.
///
- /// Arguments:
- /// - right, down, left, up, auto (splits along the larger direction)
+ /// - `auto`
+ ///
+ /// Creates a new split along the larger direction.
+ /// For example, if the parent split is currently wider than it is tall,
+ /// then a left-right split would be created, and vice versa.
///
- /// Example: Create split on the right
- /// keybind = cmd+shift+d=new_split:right
new_split: SplitDirection,
- /// Focus on a split in a given direction. For example `goto_split:up`.
- /// Valid values are left, right, up, down, previous and next.
+ /// Focus on a split either in the specified direction (`right`, `down`,
+ /// `left` and `up`), or in the adjacent split in the order of creation
+ /// (`previous` and `next`).
goto_split: SplitFocusDirection,
- /// zoom/unzoom the current split.
+ /// Zoom in or out of the current split.
+ ///
+ /// When a split is zoomed into, it will take up the entire space in
+ /// the current tab, hiding other splits. The tab or tab bar would also
+ /// reflect this by displaying an icon indicating the zoomed state.
toggle_split_zoom,
- /// Resize the current split in a given direction.
- ///
- /// Arguments:
- /// - up, down, left, right
- /// - the number of pixels to resize the split by
- ///
- /// Example: Move divider up 10 pixels
- /// keybind = cmd+shift+up=resize_split:up,10
+ /// Resize the current split in the specified direction and amount in
+ /// pixels. The two arguments should be joined with a comma (`,`),
+ /// like in `resize_split:up,10`.
resize_split: SplitResizeParameter,
- /// Equalize all splits in the current window
+ /// Equalize the size of all splits in the current window.
equalize_splits,
/// Reset the window to the default size. The "default size" is the
/// size that a new window would be created with. This has no effect
/// if the window is fullscreen.
+ ///
+ /// Only implemented on macOS.
reset_window_size,
- /// Control the terminal inspector visibility.
+ /// Control the visibility of the terminal inspector.
///
- /// Arguments:
- /// - toggle, show, hide
- ///
- /// Example: Toggle inspector visibility
- /// keybind = cmd+i=inspector:toggle
+ /// Valid arguments: `toggle`, `show`, `hide`.
inspector: InspectorMode,
- /// Open the configuration file in the default OS editor. If your default OS
- /// editor isn't configured then this will fail. Currently, any failures to
- /// open the configuration will show up only in the logs.
+ /// Show the GTK inspector.
+ ///
+ /// Has no effect on macOS.
+ show_gtk_inspector,
+
+ /// Open the configuration file in the default OS editor.
+ ///
+ /// If your default OS editor isn't configured then this will fail.
+ /// Currently, any failures to open the configuration will show up only in
+ /// the logs.
open_config,
- /// Reload the configuration. The exact meaning depends on the app runtime
- /// in use but this usually involves re-reading the configuration file
- /// and applying any changes. Note that not all changes can be applied at
- /// runtime.
+ /// Reload the configuration.
+ ///
+ /// The exact meaning depends on the app runtime in use, but this usually
+ /// involves re-reading the configuration file and applying any changes
+ /// Note that not all changes can be applied at runtime.
reload_config,
/// Close the current "surface", whether that is a window, tab, split, etc.
- /// This only closes ONE surface. This will trigger close confirmation as
- /// configured.
+ ///
+ /// This might trigger a close confirmation popup, depending on the value
+ /// of the `confirm-close-surface` configuration setting.
close_surface,
- /// Close the current tab, regardless of how many splits there may be.
- /// This will trigger close confirmation as configured.
+ /// Close the current tab and all splits therein.
+ ///
+ /// This might trigger a close confirmation popup, depending on the value
+ /// of the `confirm-close-surface` configuration setting.
close_tab,
- /// Close the window, regardless of how many tabs or splits there may be.
- /// This will trigger close confirmation as configured.
+ /// Close the current window and all tabs and splits therein.
+ ///
+ /// This might trigger a close confirmation popup, depending on the value
+ /// of the `confirm-close-surface` configuration setting.
close_window,
- /// Close all windows. This will trigger close confirmation as configured.
- /// This only works for macOS currently.
+ /// Close all windows.
+ ///
+ /// WARNING: This action has been deprecated and has no effect on either
+ /// Linux or macOS. Users are instead encouraged to use `all:close_window`
+ /// instead.
close_all_windows,
- /// Toggle maximized window state. This only works on Linux.
+ /// Maximize or unmaximize the current window.
+ ///
+ /// This has no effect on macOS as it does not have the concept of
+ /// maximized windows.
toggle_maximize,
- /// Toggle fullscreen mode of window.
+ /// Fullscreen or unfullscreen the current window.
toggle_fullscreen,
- /// Toggle window decorations on and off. This only works on Linux.
+ /// Toggle window decorations (titlebar, buttons, etc.) for the current window.
+ ///
+ /// Only implemented on Linux.
toggle_window_decorations,
- /// Toggle whether the terminal window is always on top of other
- /// windows even when it is not focused. Terminal windows always start
- /// as normal (not always on top) windows.
+ /// Toggle whether the terminal window should always float on top of other
+ /// windows even when unfocused.
+ ///
+ /// Terminal windows always start as normal (not float-on-top) windows.
///
- /// This only works on macOS.
+ /// Only implemented on macOS.
toggle_window_float_on_top,
- /// Toggle secure input mode on or off. This is used to prevent apps
- /// that monitor input from seeing what you type. This is useful for
- /// entering passwords or other sensitive information.
+ /// Toggle secure input mode.
///
- /// This applies to the entire application, not just the focused
- /// terminal. You must toggle it off to disable it, or quit Ghostty.
+ /// This is used to prevent apps from monitoring your keyboard input
+ /// when entering passwords or other sensitive information.
///
- /// This only works on macOS, since this is a system API on macOS.
+ /// This applies to the entire application, not just the focused terminal.
+ /// You must manually untoggle it or quit Ghostty entirely to disable it.
+ ///
+ /// Only implemented on macOS, as this uses a built-in system API.
toggle_secure_input,
- /// Toggle the command palette. The command palette is a UI element
- /// that lets you see what actions you can perform, their associated
- /// keybindings (if any), a search bar to filter the actions, and
- /// the ability to then execute the action.
+ /// Toggle the command palette.
+ ///
+ /// The command palette is a popup that lets you see what actions
+ /// you can perform, their associated keybindings (if any), a search bar
+ /// to filter the actions, and the ability to then execute the action.
+ ///
+ /// This requires libadwaita 1.5 or newer on Linux. The current libadwaita
+ /// version can be found by running `ghostty +version`.
toggle_command_palette,
- /// Toggle the "quick" terminal. The quick terminal is a terminal that
- /// appears on demand from a keybinding, often sliding in from a screen
- /// edge such as the top. This is useful for quick access to a terminal
- /// without having to open a new window or tab.
+ /// Toggle the quick terminal.
+ ///
+ /// The quick terminal, also known as the "Quake-style" or drop-down
+ /// terminal, is a terminal window that appears on demand from a keybinding,
+ /// often sliding in from a screen edge such as the top. This is useful for
+ /// quick access to a terminal without having to open a new window or tab.
///
- /// When the quick terminal loses focus, it disappears. The terminal state
- /// is preserved between appearances, so you can always press the keybinding
- /// to bring it back up.
+ /// The terminal state is preserved between appearances, so showing the
+ /// quick terminal after it was already hidden would display the same
+ /// window instead of creating a new one.
///
- /// To enable the quick terminal globally so that Ghostty doesn't
- /// have to be focused, prefix your keybind with `global`. Example:
+ /// As quick terminals are often useful when other windows are currently
+ /// focused, they are best used with *global* keybinds. For example, one
+ /// can define the following key bind to toggle the quick terminal from
+ /// anywhere within the system by pressing `` Cmd+` ``:
///
/// ```ini
- /// keybind = global:cmd+grave_accent=toggle_quick_terminal
+ /// keybind = global:cmd+backquote=toggle_quick_terminal
/// ```
///
/// The quick terminal has some limitations:
///
- /// - It is a singleton; only one instance can exist at a time.
- /// - It does not support tabs, but it does support splits.
- /// - It will not be restored when the application is restarted
- /// (for systems that support window restoration).
- /// - It supports fullscreen, but fullscreen will always be a non-native
- /// fullscreen (macos-non-native-fullscreen = true). This only applies
- /// to the quick terminal window. This is a requirement due to how
- /// the quick terminal is rendered.
+ /// - Only one quick terminal instance can exist at a time.
///
- /// See the various configurations for the quick terminal in the
- /// configuration file to customize its behavior.
+ /// - Unlike normal terminal windows, the quick terminal will not be
+ /// restored when the application is restarted on systems that support
+ /// window restoration like macOS.
+ ///
+ /// - On Linux, the quick terminal is only supported on Wayland and not
+ /// X11, and only on Wayland compositors that support the `wlr-layer-shell-v1`
+ /// protocol. In practice, this means that only GNOME users would not be
+ /// able to use this feature.
///
- /// Supported on macOS and some desktop environments on Linux, namely
- /// those that support the `wlr-layer-shell` Wayland protocol
- /// (i.e. most desktop environments and window managers except GNOME).
+ /// - On Linux, slide-in animations are only supported on KDE, and when
+ /// the "Sliding Popups" KWin plugin is enabled.
///
- /// Slide-in animations on Linux are only supported on KDE when the
- /// "Sliding Popups" KWin plugin is enabled. If you do not have this
- /// plugin enabled, open System Settings > Apps & Windows > Window
- /// Management > Desktop Effects, and enable the plugin in the plugin list.
- /// Ghostty would then need to be restarted for this to take effect.
+ /// If you do not have this plugin enabled, open System Settings > Apps
+ /// & Windows > Window Management > Desktop Effects, and enable the
+ /// plugin in the plugin list. Ghostty would then need to be restarted
+ /// fully for this to take effect.
+ ///
+ /// - Quick terminal tabs are only supported on Linux and not on macOS.
+ /// This is because tabs on macOS require a title bar.
+ ///
+ /// - On macOS, a fullscreened quick terminal will always be in non-native
+ /// fullscreen mode. This is a requirement due to how the quick terminal
+ /// is rendered.
+ ///
+ /// See the various configurations for the quick terminal in the
+ /// configuration file to customize its behavior.
toggle_quick_terminal,
- /// Show/hide all windows. If all windows become shown, we also ensure
+ /// Show or hide all windows. If all windows become shown, we also ensure
/// Ghostty becomes focused. When hiding all windows, focus is yielded
/// to the next application as determined by the OS.
///
/// Note: When the focused surface is fullscreen, this method does nothing.
///
- /// This currently only works on macOS.
+ /// Only implemented on macOS.
toggle_visibility,
/// Check for updates.
///
- /// This currently only works on macOS.
+ /// Only implemented on macOS.
check_for_updates,
- /// Quit ghostty.
+ /// Undo the last undoable action for the focused surface or terminal,
+ /// if possible. This can undo actions such as closing tabs or
+ /// windows.
+ ///
+ /// Not every action in Ghostty can be undone or redone. The list
+ /// of actions support undo/redo is currently limited to:
+ ///
+ /// - New window, close window
+ /// - New tab, close tab
+ /// - New split, close split
+ ///
+ /// All actions are only undoable/redoable for a limited time.
+ /// For example, restoring a closed split can only be done for
+ /// some number of seconds since the split was closed. The exact
+ /// amount is configured with `TODO`.
+ ///
+ /// The undo/redo actions being limited ensures that there is
+ /// bounded memory usage over time, closed surfaces don't continue running
+ /// in the background indefinitely, and the keybinds become available
+ /// for terminal applications to use.
+ ///
+ /// Only implemented on macOS.
+ undo,
+
+ /// Redo the last undoable action for the focused surface or terminal,
+ /// if possible. See "undo" for more details on what can and cannot
+ /// be undone or redone.
+ redo,
+
+ /// Quit Ghostty.
quit,
- /// Crash ghostty in the desired thread for the focused surface.
+ /// Crash Ghostty in the desired thread for the focused surface.
///
/// WARNING: This is a hard crash (panic) and data can be lost.
///
@@ -525,9 +703,17 @@ pub const Action = union(enum) {
///
/// The value determines the crash location:
///
- /// - "main" - crash on the main (GUI) thread.
- /// - "io" - crash on the IO thread for the focused surface.
- /// - "render" - crash on the render thread for the focused surface.
+ /// - `main`
+ ///
+ /// Crash on the main (GUI) thread.
+ ///
+ /// - `io`
+ ///
+ /// Crash on the IO thread for the focused surface.
+ ///
+ /// - `render`
+ ///
+ /// Crash on the render thread for the focused surface.
///
crash: CrashThread,
@@ -631,6 +817,7 @@ pub const Action = union(enum) {
};
pub const WriteScreenAction = enum {
+ copy,
paste,
open,
};
@@ -795,10 +982,13 @@ pub const Action = union(enum) {
.toggle_quick_terminal,
.toggle_visibility,
.check_for_updates,
+ .show_gtk_inspector,
=> .app,
// These are app but can be special-cased in a surface context.
.new_window,
+ .undo,
+ .redo,
=> .app,
// Obviously surface actions.
@@ -2039,6 +2229,32 @@ test "parse: plus sign" {
try testing.expectError(Error.InvalidFormat, parseSingle("++=ignore"));
}
+test "parse: equals sign" {
+ const testing = std.testing;
+
+ try testing.expectEqual(
+ Binding{
+ .trigger = .{ .key = .{ .unicode = '=' } },
+ .action = .ignore,
+ },
+ try parseSingle("==ignore"),
+ );
+
+ // Modifier
+ try testing.expectEqual(
+ Binding{
+ .trigger = .{
+ .key = .{ .unicode = '=' },
+ .mods = .{ .ctrl = true },
+ },
+ .action = .ignore,
+ },
+ try parseSingle("ctrl+==ignore"),
+ );
+
+ try testing.expectError(Error.InvalidFormat, parseSingle("=ignore"));
+}
+
// For Ghostty 1.2+ we changed our key names to match the W3C and removed
// `physical:`. This tests the backwards compatibility with the old format.
// Note that our backwards compatibility isn't 100% perfect since triggers
diff --git a/src/input/KeyEncoder.zig b/src/input/KeyEncoder.zig
index 41634f2f1..b5f18b5a2 100644
--- a/src/input/KeyEncoder.zig
+++ b/src/input/KeyEncoder.zig
@@ -164,7 +164,7 @@ fn kitty(
var seq: KittySequence = .{
.key = entry.code,
.final = entry.final,
- .mods = KittyMods.fromInput(
+ .mods = .fromInput(
self.event.action,
self.event.key,
all_mods,
diff --git a/src/input/command.zig b/src/input/command.zig
index 8ef4a5f0e..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());
@@ -119,7 +134,7 @@ fn actionCommands(action: Action.Key) []const Command {
.paste_from_clipboard => comptime &.{.{
.action = .paste_from_clipboard,
.title = "Paste from Clipboard",
- .description = "Paste the contents of the clipboard.",
+ .description = "Paste the contents of the main clipboard.",
}},
.paste_from_selection => comptime &.{.{
@@ -190,6 +205,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",
.description = "Copy the screen contents to a temporary file and paste the path to the file.",
@@ -203,6 +223,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",
.description = "Copy the selection contents to a temporary file and paste the path to the file.",
@@ -274,6 +299,39 @@ fn actionCommands(action: Action.Key) []const Command {
},
},
+ .goto_split => comptime &.{
+ .{
+ .action = .{ .goto_split = .previous },
+ .title = "Focus Split: Previous",
+ .description = "Focus the previous split, if any.",
+ },
+ .{
+ .action = .{ .goto_split = .next },
+ .title = "Focus Split: Next",
+ .description = "Focus the next split, if any.",
+ },
+ .{
+ .action = .{ .goto_split = .left },
+ .title = "Focus Split: Left",
+ .description = "Focus the split to the left, if it exists.",
+ },
+ .{
+ .action = .{ .goto_split = .right },
+ .title = "Focus Split: Right",
+ .description = "Focus the split to the right, if it exists.",
+ },
+ .{
+ .action = .{ .goto_split = .up },
+ .title = "Focus Split: Up",
+ .description = "Focus the split above, if it exists.",
+ },
+ .{
+ .action = .{ .goto_split = .down },
+ .title = "Focus Split: Down",
+ .description = "Focus the split below, if it exists.",
+ },
+ },
+
.toggle_split_zoom => comptime &.{.{
.action = .toggle_split_zoom,
.title = "Toggle Split Zoom",
@@ -298,6 +356,12 @@ fn actionCommands(action: Action.Key) []const Command {
.description = "Toggle the inspector.",
}},
+ .show_gtk_inspector => comptime &.{.{
+ .action = .show_gtk_inspector,
+ .title = "Show the GTK Inspector",
+ .description = "Show the GTK inspector.",
+ }},
+
.open_config => comptime &.{.{
.action = .open_config,
.title = "Open Config",
@@ -370,6 +434,18 @@ fn actionCommands(action: Action.Key) []const Command {
.description = "Check for updates to the application.",
}},
+ .undo => comptime &.{.{
+ .action = .undo,
+ .title = "Undo",
+ .description = "Undo the last action.",
+ }},
+
+ .redo => comptime &.{.{
+ .action = .redo,
+ .title = "Redo",
+ .description = "Redo the last undone action.",
+ }},
+
.quit => comptime &.{.{
.action = .quit,
.title = "Quit",
@@ -390,7 +466,6 @@ fn actionCommands(action: Action.Key) []const Command {
.jump_to_prompt,
.write_scrollback_file,
.goto_tab,
- .goto_split,
.resize_split,
.crash,
=> comptime &.{},
diff --git a/src/input/key.zig b/src/input/key.zig
index 9dad37d78..28aa3ccf4 100644
--- a/src/input/key.zig
+++ b/src/input/key.zig
@@ -454,6 +454,11 @@ pub const Key = enum(c_int) {
audio_volume_up,
wake_up,
+ // "Legacy, Non-standard, and Special Keys" § 3.7
+ copy,
+ cut,
+ paste,
+
/// Converts an ASCII character to a key, if possible. This returns
/// null if the character is unknown.
///
@@ -797,6 +802,9 @@ pub const Key = enum(c_int) {
.audio_volume_up,
.wake_up,
.help,
+ .copy,
+ .cut,
+ .paste,
=> null,
.unidentified,
diff --git a/src/input/keycodes.zig b/src/input/keycodes.zig
index b4004088e..2fa0665ea 100644
--- a/src/input/keycodes.zig
+++ b/src/input/keycodes.zig
@@ -11,7 +11,7 @@ pub const entries: []const Entry = entries: {
const native_idx = switch (builtin.os.tag) {
.ios, .macos => 4, // mac
.windows => 3, // win
- .linux => 2, // xkb
+ .freebsd, .linux => 2, // xkb
else => @compileError("unsupported platform"),
};
@@ -130,6 +130,9 @@ const code_to_key = code_to_key: {
.{ "PageUp", .page_up },
.{ "Delete", .delete },
.{ "End", .end },
+ .{ "Copy", .copy },
+ .{ "Cut", .cut },
+ .{ "Paste", .paste },
.{ "PageDown", .page_down },
.{ "ArrowRight", .arrow_right },
.{ "ArrowLeft", .arrow_left },
diff --git a/src/inspector/termio.zig b/src/inspector/termio.zig
index 6aa6628ab..5ab9d3cd4 100644
--- a/src/inspector/termio.zig
+++ b/src/inspector/termio.zig
@@ -308,7 +308,7 @@ pub const VTHandler = struct {
current_seq: usize = 1,
/// Exclude certain actions by tag.
- filter_exclude: ActionTagSet = ActionTagSet.initMany(&.{.print}),
+ filter_exclude: ActionTagSet = .initMany(&.{.print}),
filter_text: *cimgui.c.ImGuiTextFilter,
const ActionTagSet = std.EnumSet(terminal.Parser.Action.Tag);
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/args.zig b/src/os/args.zig
index 9f7401c94..a531a418b 100644
--- a/src/os/args.zig
+++ b/src/os/args.zig
@@ -12,7 +12,7 @@ const macos = @import("macos");
/// but handles macOS using NSProcessInfo instead of libc argc/argv.
pub fn iterator(allocator: Allocator) ArgIterator.InitError!ArgIterator {
//if (true) return try std.process.argsWithAllocator(allocator);
- return ArgIterator.initWithAllocator(allocator);
+ return .initWithAllocator(allocator);
}
/// Duck-typed to std.process.ArgIterator
diff --git a/src/os/cf_release_thread.zig b/src/os/cf_release_thread.zig
index dbf8e6592..445dc4864 100644
--- a/src/os/cf_release_thread.zig
+++ b/src/os/cf_release_thread.zig
@@ -8,6 +8,7 @@ const std = @import("std");
const builtin = @import("builtin");
const macos = @import("macos");
+const internal_os = @import("../os/main.zig");
const xev = @import("../global.zig").xev;
const BlockingQueue = @import("../datastruct/main.zig").BlockingQueue;
@@ -119,6 +120,13 @@ pub fn threadMain(self: *Thread) void {
fn threadMain_(self: *Thread) !void {
defer log.debug("cf release thread exited", .{});
+ // Right now, on Darwin, `std.Thread.setName` can only name the current
+ // thread, and we have no way to get the current thread from within it,
+ // so instead we use this code to name the thread instead.
+ if (builtin.os.tag.isDarwin()) {
+ internal_os.macos.pthread_setname_np(&"cf_release".*);
+ }
+
// Start the async handlers. We start these first so that they're
// registered even if anything below fails so we can drain the mailbox.
self.wakeup.wait(&self.loop, &self.wakeup_c, Thread, self, wakeupCallback);
diff --git a/src/os/cgroup.zig b/src/os/cgroup.zig
index 5645e337a..4f13921c5 100644
--- a/src/os/cgroup.zig
+++ b/src/os/cgroup.zig
@@ -56,6 +56,25 @@ pub fn create(
}
}
+/// Remove a cgroup. This will only succeed if the cgroup is empty
+/// (has no processes). The cgroup path should be relative to the
+/// cgroup root (e.g. "/user.slice/surfaces/abc123.scope").
+pub fn remove(cgroup: []const u8) !void {
+ assert(cgroup.len > 0);
+ assert(cgroup[0] == '/');
+
+ var buf: [std.fs.max_path_bytes]u8 = undefined;
+ const path = try std.fmt.bufPrint(&buf, "/sys/fs/cgroup{s}", .{cgroup});
+ std.fs.cwd().deleteDir(path) catch |err| switch (err) {
+ // If it doesn't exist, that's fine - maybe it was already cleaned up
+ error.FileNotFound => {},
+
+ // Any other error we failed to delete it so we want to notify
+ // the user.
+ else => return err,
+ };
+}
+
/// Move the given PID into the given cgroup.
pub fn moveInto(
cgroup: []const u8,
diff --git a/src/os/dbus.zig b/src/os/dbus.zig
new file mode 100644
index 000000000..99824db71
--- /dev/null
+++ b/src/os/dbus.zig
@@ -0,0 +1,21 @@
+const std = @import("std");
+const builtin = @import("builtin");
+
+/// Returns true if the program was launched by D-Bus activation.
+///
+/// On Linux GTK, this returns true if the program was launched using D-Bus
+/// activation. It will return false if Ghostty was launched any other way.
+///
+/// For other platforms and app runtimes, this returns false.
+pub fn launchedByDbusActivation() bool {
+ return switch (builtin.os.tag) {
+ // On Linux, D-Bus activation sets `DBUS_STARTER_ADDRESS` and
+ // `DBUS_STARTER_BUS_TYPE`. If these environment variables are present
+ // (no matter the value) we were launched by D-Bus activation.
+ .linux => std.posix.getenv("DBUS_STARTER_ADDRESS") != null and
+ std.posix.getenv("DBUS_STARTER_BUS_TYPE") != null,
+
+ // No other system supports D-Bus so always return false.
+ else => false,
+ };
+}
diff --git a/src/os/desktop.zig b/src/os/desktop.zig
index c73f150e0..3bc843e5c 100644
--- a/src/os/desktop.zig
+++ b/src/os/desktop.zig
@@ -30,24 +30,24 @@ pub fn launchedFromDesktop() bool {
break :macos c.getppid() == 1;
},
- // On Linux, GTK sets GIO_LAUNCHED_DESKTOP_FILE and
+ // On Linux and BSD, GTK sets GIO_LAUNCHED_DESKTOP_FILE and
// GIO_LAUNCHED_DESKTOP_FILE_PID. We only check the latter to see if
// we match the PID and assume that if we do, we were launched from
// the desktop file. Pid comparing catches the scenario where
// another terminal was launched from a desktop file and then launches
// Ghostty and Ghostty inherits the env.
- .linux => linux: {
+ .linux, .freebsd => ul: {
const gio_pid_str = posix.getenv("GIO_LAUNCHED_DESKTOP_FILE_PID") orelse
- break :linux false;
+ break :ul false;
const pid = c.getpid();
const gio_pid = std.fmt.parseInt(
@TypeOf(pid),
gio_pid_str,
10,
- ) catch break :linux false;
+ ) catch break :ul false;
- break :linux gio_pid == pid;
+ break :ul gio_pid == pid;
},
// TODO: This should have some logic to detect this. Perhaps std.builtin.subsystem
@@ -71,14 +71,14 @@ pub const DesktopEnvironment = enum {
};
/// Detect what desktop environment we are running under. This is mainly used
-/// on Linux to enable or disable certain features but there may be more uses in
+/// on Linux and BSD to enable or disable certain features but there may be more uses in
/// the future.
pub fn desktopEnvironment() DesktopEnvironment {
return switch (comptime builtin.os.tag) {
.macos => .macos,
.windows => .windows,
- .linux => de: {
- if (@inComptime()) @compileError("Checking for the desktop environment on Linux must be done at runtime.");
+ .linux, .freebsd => de: {
+ if (@inComptime()) @compileError("Checking for the desktop environment on Linux/BSD must be done at runtime.");
// Use $XDG_SESSION_DESKTOP to determine what DE we are using on Linux
// https://www.freedesktop.org/software/systemd/man/latest/pam_systemd.html#desktop=
@@ -110,7 +110,7 @@ test "desktop environment" {
switch (builtin.os.tag) {
.macos => try testing.expectEqual(.macos, desktopEnvironment()),
.windows => try testing.expectEqual(.windows, desktopEnvironment()),
- .linux => {
+ .linux, .freebsd => {
const getenv = std.posix.getenv;
const setenv = @import("env.zig").setenv;
const unsetenv = @import("env.zig").unsetenv;
diff --git a/src/os/flatpak.zig b/src/os/flatpak.zig
index 7b92a8ba9..7bd84bc27 100644
--- a/src/os/flatpak.zig
+++ b/src/os/flatpak.zig
@@ -112,6 +112,8 @@ pub const FlatpakHostCommand = struct {
pub fn spawn(self: *FlatpakHostCommand, alloc: Allocator) !u32 {
const thread = try std.Thread.spawn(.{}, threadMain, .{ self, alloc });
thread.setName("flatpak-host-command") catch {};
+ // We don't track this thread, it will terminate on its own on command exit
+ thread.detach();
// Wait for the process to start or error.
self.state_mutex.lock();
@@ -232,9 +234,10 @@ pub const FlatpakHostCommand = struct {
};
// Get our bus connection.
- var g_err: [*c]c.GError = null;
+ var g_err: ?*c.GError = null;
+ defer if (g_err) |ptr| c.g_error_free(ptr);
const bus = c.g_bus_get_sync(c.G_BUS_TYPE_SESSION, null, &g_err) orelse {
- log.warn("signal error getting bus: {s}", .{g_err.*.message});
+ log.warn("signal error getting bus: {s}", .{g_err.?.*.message});
return Error.FlatpakSetupFail;
};
defer c.g_object_unref(bus);
@@ -258,7 +261,7 @@ pub const FlatpakHostCommand = struct {
&g_err,
);
if (g_err != null) {
- log.warn("signal send error: {s}", .{g_err.*.message});
+ log.warn("signal send error: {s}", .{g_err.?.*.message});
return;
}
defer c.g_variant_unref(reply);
@@ -278,9 +281,10 @@ pub const FlatpakHostCommand = struct {
// Get our bus connection. This has to remain active until we exit
// the thread otherwise our signals won't be called.
- var g_err: [*c]c.GError = null;
+ var g_err: ?*c.GError = null;
+ defer if (g_err) |ptr| c.g_error_free(ptr);
const bus = c.g_bus_get_sync(c.G_BUS_TYPE_SESSION, null, &g_err) orelse {
- log.warn("spawn error getting bus: {s}", .{g_err.*.message});
+ log.warn("spawn error getting bus: {s}", .{g_err.?.*.message});
self.updateState(.{ .err = {} });
return;
};
@@ -308,7 +312,8 @@ pub const FlatpakHostCommand = struct {
bus: *c.GDBusConnection,
loop: *c.GMainLoop,
) !void {
- var err: [*c]c.GError = null;
+ var err: ?*c.GError = null;
+ defer if (err) |ptr| c.g_error_free(ptr);
var arena_allocator = std.heap.ArenaAllocator.init(alloc);
defer arena_allocator.deinit();
const arena = arena_allocator.allocator();
@@ -317,15 +322,15 @@ pub const FlatpakHostCommand = struct {
const fd_list = c.g_unix_fd_list_new();
defer c.g_object_unref(fd_list);
if (c.g_unix_fd_list_append(fd_list, self.stdin, &err) < 0) {
- log.warn("error adding fd: {s}", .{err.*.message});
+ log.warn("error adding fd: {s}", .{err.?.*.message});
return Error.FlatpakSetupFail;
}
if (c.g_unix_fd_list_append(fd_list, self.stdout, &err) < 0) {
- log.warn("error adding fd: {s}", .{err.*.message});
+ log.warn("error adding fd: {s}", .{err.?.*.message});
return Error.FlatpakSetupFail;
}
if (c.g_unix_fd_list_append(fd_list, self.stderr, &err) < 0) {
- log.warn("error adding fd: {s}", .{err.*.message});
+ log.warn("error adding fd: {s}", .{err.?.*.message});
return Error.FlatpakSetupFail;
}
@@ -405,7 +410,7 @@ pub const FlatpakHostCommand = struct {
null,
&err,
) orelse {
- log.warn("Flatpak.HostCommand failed: {s}", .{err.*.message});
+ log.warn("Flatpak.HostCommand failed: {s}", .{err.?.*.message});
return Error.FlatpakRPCFail;
};
defer c.g_variant_unref(reply);
diff --git a/src/os/homedir.zig b/src/os/homedir.zig
index b5629fd65..f3d6e4498 100644
--- a/src/os/homedir.zig
+++ b/src/os/homedir.zig
@@ -14,7 +14,7 @@ const Error = error{
/// is generally an expensive process so the value should be cached.
pub inline fn home(buf: []u8) !?[]const u8 {
return switch (builtin.os.tag) {
- inline .linux, .macos => try homeUnix(buf),
+ inline .linux, .freebsd, .macos => try homeUnix(buf),
.windows => try homeWindows(buf),
// iOS doesn't have a user-writable home directory
@@ -122,7 +122,7 @@ pub const ExpandError = error{
/// than `buf.len`.
pub fn expandHome(path: []const u8, buf: []u8) ExpandError![]const u8 {
return switch (builtin.os.tag) {
- .linux, .macos => try expandHomeUnix(path, buf),
+ .linux, .freebsd, .macos => try expandHomeUnix(path, buf),
.ios => return path,
else => @compileError("unimplemented"),
};
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 fd1d44ab0..1ba8a676c 100644
--- a/src/os/i18n.zig
+++ b/src/os/i18n.zig
@@ -69,23 +69,27 @@ pub const InitError = error{
/// want to set the domain for the entire application since this is also
/// used by libghostty.
pub fn init(resources_dir: []const u8) InitError!void {
- // i18n is unsupported on Windows
- if (builtin.os.tag == .windows) return;
-
- // Our resources dir is always nested below the share dir that
- // is standard for translations.
- const share_dir = std.fs.path.dirname(resources_dir) orelse
- return error.InvalidResourcesDir;
-
- // Build our locale path
- var buf: [std.fs.max_path_bytes]u8 = undefined;
- const path = std.fmt.bufPrintZ(&buf, "{s}/locale", .{share_dir}) catch
- return error.OutOfMemory;
-
- // Bind our bundle ID to the given locale path
- log.debug("binding domain={s} path={s}", .{ build_config.bundle_id, path });
- _ = bindtextdomain(build_config.bundle_id, path.ptr) orelse
- return error.OutOfMemory;
+ switch (builtin.os.tag) {
+ // i18n is unsupported on Windows
+ .windows => return,
+
+ else => {
+ // Our resources dir is always nested below the share dir that
+ // is standard for translations.
+ const share_dir = std.fs.path.dirname(resources_dir) orelse
+ return error.InvalidResourcesDir;
+
+ // Build our locale path
+ var buf: [std.fs.max_path_bytes]u8 = undefined;
+ const path = std.fmt.bufPrintZ(&buf, "{s}/locale", .{share_dir}) catch
+ return error.OutOfMemory;
+
+ // Bind our bundle ID to the given locale path
+ log.debug("binding domain={s} path={s}", .{ build_config.bundle_id, path });
+ _ = bindtextdomain(build_config.bundle_id, path.ptr) orelse
+ return error.OutOfMemory;
+ },
+ }
}
/// Set the global gettext domain to our bundle ID, allowing unqualified
diff --git a/src/os/locale.zig b/src/os/locale.zig
index 17e4d163c..b391d690f 100644
--- a/src/os/locale.zig
+++ b/src/os/locale.zig
@@ -108,11 +108,8 @@ fn setLangFromCocoa() void {
}
// Get our preferred languages and set that to the LANGUAGE
- // env var in case our language differs from our locale. We only
- // do this when the app is launched from the desktop because then
- // we're in an app bundle and we are expected to read from our
- // Bundle's preferred languages.
- if (internal_os.launchedFromDesktop()) language: {
+ // env var in case our language differs from our locale.
+ language: {
var buf: [1024]u8 = undefined;
const pref_ = preferredLanguageFromCocoa(
&buf,
diff --git a/src/os/macos.zig b/src/os/macos.zig
index ca7c81a47..100d0fe44 100644
--- a/src/os/macos.zig
+++ b/src/os/macos.zig
@@ -88,6 +88,10 @@ extern "c" fn pthread_set_qos_class_self_np(
relative_priority: c_int,
) c_int;
+pub extern "c" fn pthread_setname_np(
+ name: [*:0]const u8,
+) void;
+
pub const NSOperatingSystemVersion = extern struct {
major: i64,
minor: i64,
diff --git a/src/os/main.zig b/src/os/main.zig
index 36833f427..906e3d150 100644
--- a/src/os/main.zig
+++ b/src/os/main.zig
@@ -2,6 +2,7 @@
//! system. These aren't restricted to syscalls or low-level operations, but
//! also OS-specific features and conventions.
+const dbus = @import("dbus.zig");
const desktop = @import("desktop.zig");
const env = @import("env.zig");
const file = @import("file.zig");
@@ -12,6 +13,7 @@ const mouse = @import("mouse.zig");
const openpkg = @import("open.zig");
const pipepkg = @import("pipe.zig");
const resourcesdir = @import("resourcesdir.zig");
+const systemd = @import("systemd.zig");
// Namespaces
pub const args = @import("args.zig");
@@ -27,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;
@@ -35,6 +38,8 @@ pub const getenv = env.getenv;
pub const setenv = env.setenv;
pub const unsetenv = env.unsetenv;
pub const launchedFromDesktop = desktop.launchedFromDesktop;
+pub const launchedByDbusActivation = dbus.launchedByDbusActivation;
+pub const launchedBySystemd = systemd.launchedBySystemd;
pub const desktopEnvironment = desktop.desktopEnvironment;
pub const rlimit = file.rlimit;
pub const fixMaxFiles = file.fixMaxFiles;
@@ -51,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 f7eadd06e..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 => .{ .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);
- }
+ // 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();
+}
- try exe.collectOutput(alloc, &stdout, &stderr, output_max_size);
- _ = try exe.wait();
+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;
- // 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});
+ var stdout: std.ArrayListUnmanaged(u8) = .{};
+ var stderr: std.ArrayListUnmanaged(u8) = .{};
+ defer {
+ stdout.deinit(alloc);
+ stderr.deinit(alloc);
}
-}
-const OpenCommand = struct {
- child: std.process.Child,
- wait: bool = false,
-};
+ // 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 6f69b91d3..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,
@@ -32,12 +61,13 @@ pub fn resourcesDir(alloc: std.mem.Allocator) !?[]const u8 {
const sentinels = switch (comptime builtin.target.os.tag) {
.windows => .{"terminfo/ghostty.terminfo"},
.macos => .{"terminfo/78/xterm-ghostty"},
+ .freebsd => .{ "site-terminfo/g/ghostty", "site-terminfo/x/xterm-ghostty" },
else => .{ "terminfo/g/ghostty", "terminfo/x/xterm-ghostty" },
};
// 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.
@@ -49,17 +79,22 @@ 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" }) };
}
}
}
- // On all platforms, we look for a /usr/share style path. This
+ // On all platforms (except BSD), we look for a /usr/share style path. This
// is valid even on Mac since there is nothing that requires
// Ghostty to be in an app bundle.
inline for (sentinels) |sentinel| {
- if (try maybeDir(&dir_buf, dir, "share", sentinel)) |v| {
- return try std.fs.path.join(alloc, &.{ v, "ghostty" });
+ if (try maybeDir(
+ &dir_buf,
+ dir,
+ if (builtin.target.os.tag == .freebsd) "local/share" else "share",
+ sentinel,
+ )) |v| {
+ return .{ .app_path = try std.fs.path.join(alloc, &.{ v, "ghostty" }) };
}
}
}
@@ -68,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/os/systemd.zig b/src/os/systemd.zig
new file mode 100644
index 000000000..9b67296d6
--- /dev/null
+++ b/src/os/systemd.zig
@@ -0,0 +1,65 @@
+const std = @import("std");
+const builtin = @import("builtin");
+
+const log = std.log.scoped(.systemd);
+
+/// Returns true if the program was launched as a systemd service.
+///
+/// On Linux, this returns true if the program was launched as a systemd
+/// service. It will return false if Ghostty was launched any other way.
+///
+/// For other platforms and app runtimes, this returns false.
+pub fn launchedBySystemd() bool {
+ return switch (builtin.os.tag) {
+ .linux => linux: {
+ // On Linux, systemd sets the `INVOCATION_ID` (v232+) and the
+ // `JOURNAL_STREAM` (v231+) environment variables. If these
+ // environment variables are not present we were not launched by
+ // systemd.
+ if (std.posix.getenv("INVOCATION_ID") == null) break :linux false;
+ if (std.posix.getenv("JOURNAL_STREAM") == null) break :linux false;
+
+ // If `INVOCATION_ID` and `JOURNAL_STREAM` are present, check to make sure
+ // that our parent process is actually `systemd`, not some other terminal
+ // emulator that doesn't clean up those environment variables.
+ const ppid = std.os.linux.getppid();
+ if (ppid == 1) break :linux true;
+
+ // If the parent PID is not 1 we need to check to see if we were launched by
+ // a user systemd daemon. Do that by checking the `/proc/<ppid>/comm`
+ // to see if it ends with `systemd`.
+ var comm_path_buf: [std.fs.max_path_bytes]u8 = undefined;
+ const comm_path = std.fmt.bufPrint(&comm_path_buf, "/proc/{d}/comm", .{ppid}) catch {
+ log.err("unable to format comm path for pid {d}", .{ppid});
+ break :linux false;
+ };
+ const comm_file = std.fs.openFileAbsolute(comm_path, .{ .mode = .read_only }) catch {
+ log.err("unable to open '{s}' for reading", .{comm_path});
+ break :linux false;
+ };
+ defer comm_file.close();
+
+ // The maximum length of the command name is defined by
+ // `TASK_COMM_LEN` in the Linux kernel. This is usually 16
+ // bytes at the time of writing (Jun 2025) so its set to that.
+ // Also, since we only care to compare to "systemd", anything
+ // longer can be assumed to not be systemd.
+ const TASK_COMM_LEN = 16;
+ var comm_data_buf: [TASK_COMM_LEN]u8 = undefined;
+ const comm_size = comm_file.readAll(&comm_data_buf) catch {
+ log.err("problems reading from '{s}'", .{comm_path});
+ break :linux false;
+ };
+ const comm_data = comm_data_buf[0..comm_size];
+
+ break :linux std.mem.eql(
+ u8,
+ std.mem.trimRight(u8, comm_data, "\n"),
+ "systemd",
+ );
+ },
+
+ // No other system supports systemd so always return false.
+ else => false,
+ };
+}
diff --git a/src/pty.zig b/src/pty.zig
index a36de9adc..02906b778 100644
--- a/src/pty.zig
+++ b/src/pty.zig
@@ -99,6 +99,10 @@ const PosixPty = struct {
@cInclude("sys/ioctl.h"); // ioctl and constants
@cInclude("util.h"); // openpty()
}),
+ .freebsd => @cImport({
+ @cInclude("termios.h"); // ioctl and constants
+ @cInclude("libutil.h"); // openpty()
+ }),
else => @cImport({
@cInclude("sys/ioctl.h"); // ioctl and constants
@cInclude("pty.h");
diff --git a/src/renderer.zig b/src/renderer.zig
index 61d9a4e53..e3ed070b6 100644
--- a/src/renderer.zig
+++ b/src/renderer.zig
@@ -16,6 +16,7 @@ const cursor = @import("renderer/cursor.zig");
const message = @import("renderer/message.zig");
const size = @import("renderer/size.zig");
pub const shadertoy = @import("renderer/shadertoy.zig");
+pub const GenericRenderer = @import("renderer/generic.zig").Renderer;
pub const Metal = @import("renderer/Metal.zig");
pub const OpenGL = @import("renderer/OpenGL.zig");
pub const WebGL = @import("renderer/WebGL.zig");
@@ -56,8 +57,8 @@ pub const Impl = enum {
/// The implementation to use for the renderer. This is comptime chosen
/// so that every build has exactly one renderer implementation.
pub const Renderer = switch (build_config.renderer) {
- .metal => Metal,
- .opengl => OpenGL,
+ .metal => GenericRenderer(Metal),
+ .opengl => GenericRenderer(OpenGL),
.webgl => WebGL,
};
diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig
index 99dbc838e..3899bb8c5 100644
--- a/src/renderer/Metal.zig
+++ b/src/renderer/Metal.zig
@@ -1,547 +1,85 @@
-//! Renderer implementation for Metal.
-//!
-//! Open questions:
-//!
+//! Graphics API wrapper for Metal.
pub const Metal = @This();
const std = @import("std");
+const assert = std.debug.assert;
+const Allocator = std.mem.Allocator;
const builtin = @import("builtin");
const glfw = @import("glfw");
const objc = @import("objc");
const macos = @import("macos");
-const imgui = @import("imgui");
-const glslang = @import("glslang");
-const xev = @import("../global.zig").xev;
+const graphics = macos.graphics;
const apprt = @import("../apprt.zig");
-const configpkg = @import("../config.zig");
const font = @import("../font/main.zig");
-const os = @import("../os/main.zig");
-const terminal = @import("../terminal/main.zig");
-const renderer = @import("../renderer.zig");
-const math = @import("../math.zig");
-const Surface = @import("../Surface.zig");
-const link = @import("link.zig");
-const graphics = macos.graphics;
-const fgMode = @import("cell.zig").fgMode;
-const isCovering = @import("cell.zig").isCovering;
+const configpkg = @import("../config.zig");
+const rendererpkg = @import("../renderer.zig");
+const Renderer = rendererpkg.GenericRenderer(Metal);
const shadertoy = @import("shadertoy.zig");
-const assert = std.debug.assert;
-const Allocator = std.mem.Allocator;
-const ArenaAllocator = std.heap.ArenaAllocator;
-const CFReleaseThread = os.CFReleaseThread;
-const Terminal = terminal.Terminal;
-const Health = renderer.Health;
const mtl = @import("metal/api.zig");
-const mtl_buffer = @import("metal/buffer.zig");
-const mtl_cell = @import("metal/cell.zig");
-const mtl_image = @import("metal/image.zig");
-const mtl_sampler = @import("metal/sampler.zig");
-const mtl_shaders = @import("metal/shaders.zig");
-const Image = mtl_image.Image;
-const ImageMap = mtl_image.ImageMap;
-const Shaders = mtl_shaders.Shaders;
+const IOSurfaceLayer = @import("metal/IOSurfaceLayer.zig");
-const ImageBuffer = mtl_buffer.Buffer(mtl_shaders.Image);
-const InstanceBuffer = mtl_buffer.Buffer(u16);
+pub const GraphicsAPI = Metal;
+pub const Target = @import("metal/Target.zig");
+pub const Frame = @import("metal/Frame.zig");
+pub const RenderPass = @import("metal/RenderPass.zig");
+pub const Pipeline = @import("metal/Pipeline.zig");
+const bufferpkg = @import("metal/buffer.zig");
+pub const Buffer = bufferpkg.Buffer;
+pub const Texture = @import("metal/Texture.zig");
+pub const shaders = @import("metal/shaders.zig");
-const ImagePlacementList = std.ArrayListUnmanaged(mtl_image.Placement);
+pub const custom_shader_target: shadertoy.Target = .msl;
+// The fragCoord for Metal shaders is +Y = down.
+pub const custom_shader_y_is_down = true;
-const DisplayLink = switch (builtin.os.tag) {
- .macos => *macos.video.DisplayLink,
- else => void,
-};
+/// Triple buffering.
+pub const swap_chain_count = 3;
+
+const log = std.log.scoped(.metal);
// Get native API access on certain platforms so we can do more customization.
const glfwNative = glfw.Native(.{
.cocoa = builtin.os.tag == .macos,
});
-const log = std.log.scoped(.metal);
-
-/// Allocator that can be used
-alloc: std.mem.Allocator,
-
-/// The configuration we need derived from the main config.
-config: DerivedConfig,
-
-/// The mailbox for communicating with the window.
-surface_mailbox: apprt.surface.Mailbox,
-
-/// Current font metrics defining our grid.
-grid_metrics: font.Metrics,
-
-/// The size of everything.
-size: renderer.Size,
-
-/// True if the window is focused
-focused: bool,
-
-/// The foreground color set by an OSC 10 sequence. If unset then
-/// default_foreground_color is used.
-foreground_color: ?terminal.color.RGB,
-
-/// Foreground color set in the user's config file.
-default_foreground_color: terminal.color.RGB,
-
-/// The background color set by an OSC 11 sequence. If unset then
-/// default_background_color is used.
-background_color: ?terminal.color.RGB,
-
-/// Background color set in the user's config file.
-default_background_color: terminal.color.RGB,
-
-/// The cursor color set by an OSC 12 sequence. If unset then
-/// default_cursor_color is used.
-cursor_color: ?terminal.color.RGB,
-
-/// Default cursor color when no color is set explicitly by an OSC 12 command.
-/// This is cursor color as set in the user's config, if any. If no cursor color
-/// is set in the user's config, then the cursor color is determined by the
-/// current foreground color.
-default_cursor_color: ?terminal.color.RGB,
-
-/// When `cursor_color` is null, swap the foreground and background colors of
-/// the cell under the cursor for the cursor color. Otherwise, use the default
-/// foreground color as the cursor color.
-cursor_invert: bool,
-
-/// The current set of cells to render. This is rebuilt on every frame
-/// but we keep this around so that we don't reallocate. Each set of
-/// cells goes into a separate shader.
-cells: mtl_cell.Contents,
-
-/// The last viewport that we based our rebuild off of. If this changes,
-/// then we do a full rebuild of the cells. The pointer values in this pin
-/// are NOT SAFE to read because they may be modified, freed, etc from the
-/// termio thread. We treat the pointers as integers for comparison only.
-cells_viewport: ?terminal.Pin = null,
-
-/// Set to true after rebuildCells is called. This can be used
-/// to determine if any possible changes have been made to the
-/// cells for the draw call.
-cells_rebuilt: bool = false,
-
-/// The current GPU uniform values.
-uniforms: mtl_shaders.Uniforms,
-
-/// The font structures.
-font_grid: *font.SharedGrid,
-font_shaper: font.Shaper,
-font_shaper_cache: font.ShaperCache,
-
-/// The images that we may render.
-images: ImageMap = .{},
-image_placements: ImagePlacementList = .{},
-image_bg_end: u32 = 0,
-image_text_end: u32 = 0,
-image_virtual: bool = false,
-
-/// Metal state
-shaders: Shaders, // Compiled shaders
-
-/// Metal objects
-layer: objc.Object, // CAMetalLayer
-
-/// The CVDisplayLink used to drive the rendering loop in sync
-/// with the display. This is void on platforms that don't support
-/// a display link.
-display_link: ?DisplayLink = null,
-
-/// The `CGColorSpace` that represents our current terminal color space
-terminal_colorspace: *graphics.ColorSpace,
-
-/// Custom shader state. This is only set if we have custom shaders.
-custom_shader_state: ?CustomShaderState = null,
-
-/// Health of the last frame. Note that when we do double/triple buffering
-/// this will have to be part of the frame state.
-health: std.atomic.Value(Health) = .{ .raw = .healthy },
-
-/// Our GPU state
-gpu_state: GPUState,
-
-/// State we need for the GPU that is shared between all frames.
-pub const GPUState = struct {
- // The count of buffers we use for double/triple buffering. If
- // this is one then we don't do any double+ buffering at all. This
- // is comptime because there isn't a good reason to change this at
- // runtime and there is a lot of complexity to support it. For comptime,
- // this is useful for debugging.
- const BufferCount = 3;
-
- /// The frame data, the current frame index, and the semaphore protecting
- /// the frame data. This is used to implement double/triple/etc. buffering.
- frames: [BufferCount]FrameState,
- frame_index: std.math.IntFittingRange(0, BufferCount) = 0,
- frame_sema: std.Thread.Semaphore = .{ .permits = BufferCount },
-
- device: objc.Object, // MTLDevice
- queue: objc.Object, // MTLCommandQueue
-
- /// This buffer is written exactly once so we can use it globally.
- instance: InstanceBuffer, // MTLBuffer
-
- /// The default storage mode to use for resources created with our device.
- ///
- /// This is based on whether the device is a discrete GPU or not, since
- /// discrete GPUs do not have unified memory and therefore do not support
- /// the "shared" storage mode, instead we have to use the "managed" mode.
- default_storage_mode: mtl.MTLResourceOptions.StorageMode,
-
- pub fn init() !GPUState {
- const device = try chooseDevice();
- const queue = device.msgSend(objc.Object, objc.sel("newCommandQueue"), .{});
- errdefer queue.release();
-
- // We determine whether our device is a discrete GPU based on these:
- // - We're on macOS (iOS, iPadOS, etc. are guaranteed to be integrated).
- // - We're not on aarch64 (Apple Silicon, therefore integrated).
- // - The device reports that it does not have unified memory.
- const is_discrete =
- builtin.target.os.tag == .macos and
- builtin.target.cpu.arch != .aarch64 and
- !device.getProperty(bool, "hasUnifiedMemory");
+layer: IOSurfaceLayer,
- const default_storage_mode: mtl.MTLResourceOptions.StorageMode =
- if (is_discrete) .managed else .shared;
+/// MTLDevice
+device: objc.Object,
+/// MTLCommandQueue
+queue: objc.Object,
- var instance = try InstanceBuffer.initFill(device, &.{
- 0, 1, 3, // Top-left triangle
- 1, 2, 3, // Bottom-right triangle
- }, .{ .storage_mode = default_storage_mode });
- errdefer instance.deinit();
+/// Alpha blending mode
+blending: configpkg.Config.AlphaBlending,
- var result: GPUState = .{
- .device = device,
- .queue = queue,
- .instance = instance,
- .frames = undefined,
- .default_storage_mode = default_storage_mode,
- };
-
- // Initialize all of our frame state.
- for (&result.frames) |*frame| {
- frame.* = try FrameState.init(result.device, default_storage_mode);
- }
-
- return result;
- }
-
- fn chooseDevice() error{NoMetalDevice}!objc.Object {
- var chosen_device: ?objc.Object = null;
-
- switch (comptime builtin.os.tag) {
- .macos => {
- const devices = objc.Object.fromId(mtl.MTLCopyAllDevices());
- defer devices.release();
-
- var iter = devices.iterate();
- while (iter.next()) |device| {
- // We want a GPU that’s connected to a display.
- if (device.getProperty(bool, "isHeadless")) continue;
- chosen_device = device;
- // If the user has an eGPU plugged in, they probably want
- // to use it. Otherwise, integrated GPUs are better for
- // battery life and thermals.
- if (device.getProperty(bool, "isRemovable") or
- device.getProperty(bool, "isLowPower")) break;
- }
- },
- .ios => {
- chosen_device = objc.Object.fromId(mtl.MTLCreateSystemDefaultDevice());
- },
- else => @compileError("unsupported target for Metal"),
- }
-
- const device = chosen_device orelse return error.NoMetalDevice;
- return device.retain();
- }
-
- pub fn deinit(self: *GPUState) void {
- // Wait for all of our inflight draws to complete so that
- // we can cleanly deinit our GPU state.
- for (0..BufferCount) |_| self.frame_sema.wait();
- for (&self.frames) |*frame| frame.deinit();
- self.instance.deinit();
- self.queue.release();
- self.device.release();
- }
-
- /// Get the next frame state to draw to. This will wait on the
- /// semaphore to ensure that the frame is available. This must
- /// always be paired with a call to releaseFrame.
- pub fn nextFrame(self: *GPUState) *FrameState {
- self.frame_sema.wait();
- errdefer self.frame_sema.post();
- self.frame_index = (self.frame_index + 1) % BufferCount;
- return &self.frames[self.frame_index];
- }
-
- /// This should be called when the frame has completed drawing.
- pub fn releaseFrame(self: *GPUState) void {
- self.frame_sema.post();
- }
-};
-
-/// State we need duplicated for every frame. Any state that could be
-/// in a data race between the GPU and CPU while a frame is being
-/// drawn should be in this struct.
-///
-/// While a draw is in-process, we "lock" the state (via a semaphore)
-/// and prevent the CPU from updating the state until Metal reports
-/// that the frame is complete.
+/// The default storage mode to use for resources created with our device.
///
-/// This is used to implement double/triple buffering.
-pub const FrameState = struct {
- uniforms: UniformBuffer,
- cells: CellTextBuffer,
- cells_bg: CellBgBuffer,
-
- grayscale: objc.Object, // MTLTexture
- grayscale_modified: usize = 0,
- color: objc.Object, // MTLTexture
- color_modified: usize = 0,
-
- /// A buffer containing the uniform data.
- const UniformBuffer = mtl_buffer.Buffer(mtl_shaders.Uniforms);
- const CellBgBuffer = mtl_buffer.Buffer(mtl_shaders.CellBg);
- const CellTextBuffer = mtl_buffer.Buffer(mtl_shaders.CellText);
-
- pub fn init(
- device: objc.Object,
- /// Storage mode for buffers and textures.
- storage_mode: mtl.MTLResourceOptions.StorageMode,
- ) !FrameState {
- // Uniform buffer contains exactly 1 uniform struct. The
- // uniform data will be undefined so this must be set before
- // a frame is drawn.
- var uniforms = try UniformBuffer.init(
- device,
- 1,
- .{
- // Indicate that the CPU writes to this resource but never reads it.
- .cpu_cache_mode = .write_combined,
- .storage_mode = storage_mode,
- },
- );
- errdefer uniforms.deinit();
-
- // Create the buffers for our vertex data. The preallocation size
- // is likely too small but our first frame update will resize it.
- var cells = try CellTextBuffer.init(
- device,
- 10 * 10,
- .{
- // Indicate that the CPU writes to this resource but never reads it.
- .cpu_cache_mode = .write_combined,
- .storage_mode = storage_mode,
- },
- );
- errdefer cells.deinit();
- var cells_bg = try CellBgBuffer.init(
- device,
- 10 * 10,
- .{
- // Indicate that the CPU writes to this resource but never reads it.
- .cpu_cache_mode = .write_combined,
- .storage_mode = storage_mode,
- },
- );
-
- errdefer cells_bg.deinit();
-
- // Initialize our textures for our font atlas.
- const grayscale = try initAtlasTexture(device, &.{
- .data = undefined,
- .size = 8,
- .format = .grayscale,
- }, storage_mode);
- errdefer grayscale.release();
- const color = try initAtlasTexture(device, &.{
- .data = undefined,
- .size = 8,
- .format = .rgba,
- }, storage_mode);
- errdefer color.release();
-
- return .{
- .uniforms = uniforms,
- .cells = cells,
- .cells_bg = cells_bg,
- .grayscale = grayscale,
- .color = color,
- };
- }
-
- pub fn deinit(self: *FrameState) void {
- self.uniforms.deinit();
- self.cells.deinit();
- self.cells_bg.deinit();
- self.grayscale.release();
- self.color.release();
- }
-};
-
-pub const CustomShaderState = struct {
- /// When we have a custom shader state, we maintain a front
- /// and back texture which we use as a swap chain to render
- /// between when multiple custom shaders are defined.
- front_texture: objc.Object, // MTLTexture
- back_texture: objc.Object, // MTLTexture
-
- sampler: mtl_sampler.Sampler,
- uniforms: mtl_shaders.PostUniforms,
-
- /// The first time a frame was drawn.
- /// This is used to update the time uniform.
- first_frame_time: std.time.Instant,
-
- /// The last time a frame was drawn.
- /// This is used to update the time uniform.
- last_frame_time: std.time.Instant,
+/// This is based on whether the device is a discrete GPU or not, since
+/// discrete GPUs do not have unified memory and therefore do not support
+/// the "shared" storage mode, instead we have to use the "managed" mode.
+default_storage_mode: mtl.MTLResourceOptions.StorageMode,
- /// Swap the front and back textures.
- pub fn swap(self: *CustomShaderState) void {
- std.mem.swap(objc.Object, &self.front_texture, &self.back_texture);
- }
-
- pub fn deinit(self: *CustomShaderState) void {
- self.front_texture.release();
- self.back_texture.release();
- self.sampler.deinit();
- }
-};
-
-/// The configuration for this renderer that is derived from the main
-/// configuration. This must be exported so that we don't need to
-/// pass around Config pointers which makes memory management a pain.
-pub const DerivedConfig = struct {
- arena: ArenaAllocator,
-
- font_thicken: bool,
- font_thicken_strength: u8,
- font_features: std.ArrayListUnmanaged([:0]const u8),
- font_styles: font.CodepointResolver.StyleStatus,
- cursor_color: ?terminal.color.RGB,
- cursor_invert: bool,
- cursor_opacity: f64,
- cursor_text: ?terminal.color.RGB,
- background: terminal.color.RGB,
- background_opacity: f64,
- foreground: terminal.color.RGB,
- selection_background: ?terminal.color.RGB,
- selection_foreground: ?terminal.color.RGB,
- invert_selection_fg_bg: bool,
- bold_is_bright: bool,
- min_contrast: f32,
- padding_color: configpkg.WindowPaddingColor,
- custom_shaders: configpkg.RepeatablePath,
- links: link.Set,
- vsync: bool,
- colorspace: configpkg.Config.WindowColorspace,
- blending: configpkg.Config.AlphaBlending,
-
- pub fn init(
- alloc_gpa: Allocator,
- config: *const configpkg.Config,
- ) !DerivedConfig {
- var arena = ArenaAllocator.init(alloc_gpa);
- errdefer arena.deinit();
- const alloc = arena.allocator();
-
- // Copy our shaders
- const custom_shaders = try config.@"custom-shader".clone(alloc);
-
- // Copy our font features
- const font_features = try config.@"font-feature".clone(alloc);
-
- // Get our font styles
- var font_styles = font.CodepointResolver.StyleStatus.initFill(true);
- font_styles.set(.bold, config.@"font-style-bold" != .false);
- font_styles.set(.italic, config.@"font-style-italic" != .false);
- font_styles.set(.bold_italic, config.@"font-style-bold-italic" != .false);
-
- // Our link configs
- const links = try link.Set.fromConfig(
- alloc,
- config.link.links.items,
- );
-
- const cursor_invert = config.@"cursor-invert-fg-bg";
-
- return .{
- .background_opacity = @max(0, @min(1, config.@"background-opacity")),
- .font_thicken = config.@"font-thicken",
- .font_thicken_strength = config.@"font-thicken-strength",
- .font_features = font_features.list,
- .font_styles = font_styles,
-
- .cursor_color = if (!cursor_invert and config.@"cursor-color" != null)
- config.@"cursor-color".?.toTerminalRGB()
- else
- null,
-
- .cursor_invert = cursor_invert,
-
- .cursor_text = if (config.@"cursor-text") |txt|
- txt.toTerminalRGB()
- else
- null,
-
- .cursor_opacity = @max(0, @min(1, config.@"cursor-opacity")),
-
- .background = config.background.toTerminalRGB(),
- .foreground = config.foreground.toTerminalRGB(),
- .invert_selection_fg_bg = config.@"selection-invert-fg-bg",
- .bold_is_bright = config.@"bold-is-bright",
- .min_contrast = @floatCast(config.@"minimum-contrast"),
- .padding_color = config.@"window-padding-color",
-
- .selection_background = if (config.@"selection-background") |bg|
- bg.toTerminalRGB()
- else
- null,
-
- .selection_foreground = if (config.@"selection-foreground") |bg|
- bg.toTerminalRGB()
- else
- null,
-
- .custom_shaders = custom_shaders,
- .links = links,
- .vsync = config.@"window-vsync",
- .colorspace = config.@"window-colorspace",
- .blending = config.@"alpha-blending",
- .arena = arena,
- };
- }
+/// We start an AutoreleasePool before `drawFrame` and end it afterwards.
+autorelease_pool: ?*objc.AutoreleasePool = null,
- pub fn deinit(self: *DerivedConfig) void {
- const alloc = self.arena.allocator();
- self.links.deinit(alloc);
- self.arena.deinit();
- }
-};
-
-/// Returns the hints that we want for this
-pub fn glfwWindowHints(config: *const configpkg.Config) glfw.Window.Hints {
- return .{
- .client_api = .no_api,
- .transparent_framebuffer = config.@"background-opacity" < 1,
+pub fn init(alloc: Allocator, opts: rendererpkg.Options) !Metal {
+ comptime switch (builtin.os.tag) {
+ .macos, .ios => {},
+ else => @compileError("unsupported platform for Metal"),
};
-}
-/// This is called early right after window creation to setup our
-/// window surface as necessary.
-pub fn surfaceInit(surface: *apprt.Surface) !void {
- _ = surface;
+ _ = alloc;
- // We don't do anything else here because we want to set everything
- // else up during actual initialization.
-}
+ // Choose our MTLDevice and create a MTLCommandQueue for that device.
+ const device = try chooseDevice();
+ errdefer device.release();
+ const queue = device.msgSend(objc.Object, objc.sel("newCommandQueue"), .{});
+ errdefer queue.release();
+
+ const default_storage_mode: mtl.MTLResourceOptions.StorageMode =
+ if (device.getProperty(bool, "hasUnifiedMemory")) .shared else .managed;
-pub fn init(alloc: Allocator, options: renderer.Options) !Metal {
const ViewInfo = struct {
view: objc.Object,
scaleFactor: f64,
@@ -553,7 +91,7 @@ pub fn init(alloc: Allocator, options: renderer.Options) !Metal {
// Everything in glfw is window-oriented so we grab the backing
// window, then derive everything from that.
const nswindow = objc.Object.fromId(glfwNative.getCocoaWindow(
- options.rt_surface.window,
+ opts.rt_surface.window,
).?);
const contentView = objc.Object.fromId(
@@ -571,8 +109,8 @@ pub fn init(alloc: Allocator, options: renderer.Options) !Metal {
},
apprt.embedded => .{
- .scaleFactor = @floatCast(options.rt_surface.content_scale.x),
- .view = switch (options.rt_surface.platform) {
+ .scaleFactor = @floatCast(opts.rt_surface.content_scale.x),
+ .view = switch (opts.rt_surface.platform) {
.macos => |v| v.nsview,
.ios => |v| v.uiview,
},
@@ -581,2768 +119,293 @@ pub fn init(alloc: Allocator, options: renderer.Options) !Metal {
else => @compileError("unsupported apprt for metal"),
};
- // Initialize our metal stuff
- var gpu_state = try GPUState.init();
- errdefer gpu_state.deinit();
+ // Create an IOSurfaceLayer which we can assign to the view to make
+ // it in to a "layer-hosting view", so that we can manually control
+ // the layer contents.
+ var layer = try IOSurfaceLayer.init();
+ errdefer layer.release();
- // Get our CAMetalLayer
- const layer: objc.Object = switch (builtin.os.tag) {
- .macos => layer: {
- const CAMetalLayer = objc.getClass("CAMetalLayer").?;
- break :layer CAMetalLayer.msgSend(objc.Object, objc.sel("layer"), .{});
+ // Add our layer to the view.
+ //
+ // On macOS we do this by making the view "layer-hosting"
+ // by assigning it to the view's `layer` property BEFORE
+ // setting `wantsLayer` to `true`.
+ //
+ // On iOS, views are always layer-backed, and `layer`
+ // is readonly, so instead we add it as a sublayer.
+ switch (comptime builtin.os.tag) {
+ .macos => {
+ info.view.setProperty("layer", layer.layer.value);
+ info.view.setProperty("wantsLayer", true);
},
- // iOS is always layer-backed so we don't need to do anything here.
- .ios => info.view.getProperty(objc.Object, "layer"),
-
- else => @compileError("unsupported target for Metal"),
- };
- layer.setProperty("device", gpu_state.device.value);
- layer.setProperty("opaque", options.config.background_opacity >= 1);
- layer.setProperty("displaySyncEnabled", options.config.vsync);
-
- // Set our layer's pixel format appropriately.
- layer.setProperty(
- "pixelFormat",
- // Using an `*_srgb` pixel format makes Metal gamma encode
- // the pixels written to it *after* blending, which means
- // we get linear alpha blending rather than gamma-incorrect
- // blending.
- if (options.config.blending.isLinear())
- @intFromEnum(mtl.MTLPixelFormat.bgra8unorm_srgb)
- else
- @intFromEnum(mtl.MTLPixelFormat.bgra8unorm),
- );
-
- // Set our layer's color space to Display P3.
- // This allows us to have "Apple-style" alpha blending,
- // since it seems to be the case that Apple apps like
- // Terminal and TextEdit render text in the display's
- // color space using converted colors, which reduces,
- // but does not fully eliminate blending artifacts.
- const colorspace = try graphics.ColorSpace.createNamed(.displayP3);
- defer colorspace.release();
- layer.setProperty("colorspace", colorspace);
-
- // Create a colorspace the represents our terminal colors
- // this will allow us to create e.g. `CGColor`s for things
- // like the current background color.
- const terminal_colorspace = try graphics.ColorSpace.createNamed(
- switch (options.config.colorspace) {
- .@"display-p3" => .displayP3,
- .srgb => .sRGB,
+ .ios => {
+ info.view.msgSend(void, objc.sel("addSublayer"), .{layer.layer.value});
},
- );
- errdefer terminal_colorspace.release();
- // Make our view layer-backed with our Metal layer. On iOS views are
- // always layer backed so we don't need to do this. But on iOS the
- // caller MUST be sure to set the layerClass to CAMetalLayer.
- if (comptime builtin.os.tag == .macos) {
- info.view.setProperty("layer", layer.value);
- info.view.setProperty("wantsLayer", true);
-
- // The layer gravity is set to top-left so that when we resize
- // the view, the contents aren't stretched before a redraw.
- layer.setProperty("contentsGravity", macos.animation.kCAGravityTopLeft);
+ else => @compileError("unsupported target for Metal"),
}
- // Ensure that our metal layer has a content scale set to match the
- // scale factor of the window. This avoids magnification issues leading
- // to blurry rendering.
- layer.setProperty("contentsScale", info.scaleFactor);
-
- // Create the font shaper. We initially create a shaper that can support
- // a width of 160 which is a common width for modern screens to help
- // avoid allocations later.
- var font_shaper = try font.Shaper.init(alloc, .{
- .features = options.config.font_features.items,
- });
- errdefer font_shaper.deinit();
-
- // Initialize all the data that requires a critical font section.
- const font_critical: struct {
- metrics: font.Metrics,
- } = font_critical: {
- const grid = options.font_grid;
- grid.lock.lockShared();
- defer grid.lock.unlockShared();
- break :font_critical .{
- .metrics = grid.metrics,
- };
- };
-
- const display_link: ?DisplayLink = switch (builtin.os.tag) {
- .macos => if (options.config.vsync)
- try macos.video.DisplayLink.createWithActiveCGDisplays()
- else
- null,
- else => null,
- };
- errdefer if (display_link) |v| v.release();
+ // Ensure that if our layer is oversized it
+ // does not overflow the bounds of the view.
+ info.view.setProperty("clipsToBounds", true);
- var result: Metal = .{
- .alloc = alloc,
- .config = options.config,
- .surface_mailbox = options.surface_mailbox,
- .grid_metrics = font_critical.metrics,
- .size = options.size,
- .focused = true,
- .foreground_color = null,
- .default_foreground_color = options.config.foreground,
- .background_color = null,
- .default_background_color = options.config.background,
- .cursor_color = null,
- .default_cursor_color = options.config.cursor_color,
- .cursor_invert = options.config.cursor_invert,
+ // Ensure that our layer has a content scale set to
+ // match the scale factor of the window. This avoids
+ // magnification issues leading to blurry rendering.
+ layer.layer.setProperty("contentsScale", info.scaleFactor);
- // Render state
- .cells = .{},
- .uniforms = .{
- .projection_matrix = undefined,
- .cell_size = undefined,
- .grid_size = undefined,
- .grid_padding = undefined,
- .padding_extend = .{},
- .min_contrast = options.config.min_contrast,
- .cursor_pos = .{ std.math.maxInt(u16), std.math.maxInt(u16) },
- .cursor_color = undefined,
- .bg_color = .{
- options.config.background.r,
- options.config.background.g,
- options.config.background.b,
- @intFromFloat(@round(options.config.background_opacity * 255.0)),
- },
- .cursor_wide = false,
- .use_display_p3 = options.config.colorspace == .@"display-p3",
- .use_linear_blending = options.config.blending.isLinear(),
- .use_linear_correction = options.config.blending == .@"linear-corrected",
- },
+ // This makes it so that our display callback will actually be called.
+ layer.layer.setProperty("needsDisplayOnBoundsChange", true);
- // Fonts
- .font_grid = options.font_grid,
- .font_shaper = font_shaper,
- .font_shaper_cache = font.ShaperCache.init(),
-
- // Shaders (initialized below)
- .shaders = undefined,
-
- // Metal stuff
+ return .{
.layer = layer,
- .display_link = display_link,
- .terminal_colorspace = terminal_colorspace,
- .custom_shader_state = null,
- .gpu_state = gpu_state,
+ .device = device,
+ .queue = queue,
+ .blending = opts.config.blending,
+ .default_storage_mode = default_storage_mode,
};
-
- try result.initShaders();
-
- // Do an initialize screen size setup to ensure our undefined values
- // above are initialized.
- try result.setScreenSize(result.size);
-
- return result;
}
pub fn deinit(self: *Metal) void {
- self.gpu_state.deinit();
-
- if (DisplayLink != void) {
- if (self.display_link) |display_link| {
- display_link.stop() catch {};
- display_link.release();
- }
- }
-
- self.terminal_colorspace.release();
-
- self.cells.deinit(self.alloc);
-
- self.font_shaper.deinit();
- self.font_shaper_cache.deinit(self.alloc);
-
- self.config.deinit();
-
- {
- var it = self.images.iterator();
- while (it.next()) |kv| kv.value_ptr.image.deinit(self.alloc);
- self.images.deinit(self.alloc);
- }
- self.image_placements.deinit(self.alloc);
-
- self.deinitShaders();
-
- self.* = undefined;
+ self.queue.release();
+ self.device.release();
+ self.layer.release();
}
-fn deinitShaders(self: *Metal) void {
- if (self.custom_shader_state) |*state| state.deinit();
-
- self.shaders.deinit(self.alloc);
+pub fn loopEnter(self: *Metal) void {
+ const renderer: *align(1) Renderer = @fieldParentPtr("api", self);
+ self.layer.setDisplayCallback(
+ @ptrCast(&displayCallback),
+ @ptrCast(renderer),
+ );
}
-fn initShaders(self: *Metal) !void {
- var arena = ArenaAllocator.init(self.alloc);
- defer arena.deinit();
- const arena_alloc = arena.allocator();
-
- // Load our custom shaders
- const custom_shaders: []const [:0]const u8 = shadertoy.loadFromFiles(
- arena_alloc,
- self.config.custom_shaders,
- .msl,
- ) catch |err| err: {
- log.warn("error loading custom shaders err={}", .{err});
- break :err &.{};
+fn displayCallback(renderer: *Renderer) align(8) void {
+ renderer.drawFrame(true) catch |err| {
+ log.warn("Error drawing frame in display callback, err={}", .{err});
};
+}
- var custom_shader_state: ?CustomShaderState = state: {
- if (custom_shaders.len == 0) break :state null;
-
- // Build our sampler for our texture
- var sampler = try mtl_sampler.Sampler.init(self.gpu_state.device);
- errdefer sampler.deinit();
-
- break :state .{
- // Resolution and screen textures will be fixed up by first
- // call to setScreenSize. Draw calls will bail out early if
- // the screen size hasn't been set yet, so it won't error.
- .front_texture = undefined,
- .back_texture = undefined,
- .sampler = sampler,
- .uniforms = .{
- .resolution = .{ 0, 0, 1 },
- .time = 1,
- .time_delta = 1,
- .frame_rate = 1,
- .frame = 1,
- .channel_time = [1][4]f32{.{ 0, 0, 0, 0 }} ** 4,
- .channel_resolution = [1][4]f32{.{ 0, 0, 0, 0 }} ** 4,
- .mouse = .{ 0, 0, 0, 0 },
- .date = .{ 0, 0, 0, 0 },
- .sample_rate = 1,
- },
-
- .first_frame_time = try std.time.Instant.now(),
- .last_frame_time = try std.time.Instant.now(),
- };
- };
- errdefer if (custom_shader_state) |*state| state.deinit();
+/// Actions taken before doing anything in `drawFrame`.
+///
+/// Right now we use this to start an AutoreleasePool.
+pub fn drawFrameStart(self: *Metal) void {
+ assert(self.autorelease_pool == null);
+ self.autorelease_pool = .init();
+}
- var shaders = try Shaders.init(
- self.alloc,
- self.gpu_state.device,
+/// Actions taken after `drawFrame` is done.
+///
+/// Right now we use this to end our AutoreleasePool.
+pub fn drawFrameEnd(self: *Metal) void {
+ assert(self.autorelease_pool != null);
+ self.autorelease_pool.?.deinit();
+ self.autorelease_pool = null;
+}
+
+pub fn initShaders(
+ self: *const Metal,
+ alloc: Allocator,
+ custom_shaders: []const [:0]const u8,
+) !shaders.Shaders {
+ return try shaders.Shaders.init(
+ alloc,
+ self.device,
custom_shaders,
// Using an `*_srgb` pixel format makes Metal gamma encode
// the pixels written to it *after* blending, which means
// we get linear alpha blending rather than gamma-incorrect
// blending.
- if (self.config.blending.isLinear())
+ if (self.blending.isLinear())
mtl.MTLPixelFormat.bgra8unorm_srgb
else
mtl.MTLPixelFormat.bgra8unorm,
);
- errdefer shaders.deinit(self.alloc);
-
- self.shaders = shaders;
- self.custom_shader_state = custom_shader_state;
}
-/// This is called just prior to spinning up the renderer thread for
-/// final main thread setup requirements.
-pub fn finalizeSurfaceInit(self: *Metal, surface: *apprt.Surface) !void {
- _ = self;
- _ = surface;
-
- // Metal doesn't have to do anything here. OpenGL has to do things
- // like release the context but Metal doesn't have anything like that.
-}
-
-/// Callback called by renderer.Thread when it begins.
-pub fn threadEnter(self: *const Metal, surface: *apprt.Surface) !void {
- _ = self;
- _ = surface;
-
- // Metal requires no per-thread state.
-}
-
-/// Callback called by renderer.Thread when it exits.
-pub fn threadExit(self: *const Metal) void {
- _ = self;
-
- // Metal requires no per-thread state.
-}
-
-/// Called by renderer.Thread when it starts the main loop.
-pub fn loopEnter(self: *Metal, thr: *renderer.Thread) !void {
- // If we don't support a display link we have no work to do.
- if (comptime DisplayLink == void) return;
-
- // This is when we know our "self" pointer is stable so we can
- // setup the display link. To setup the display link we set our
- // callback and we can start it immediately.
- const display_link = self.display_link orelse return;
- try display_link.setOutputCallback(
- xev.Async,
- &displayLinkCallback,
- &thr.draw_now,
- );
- display_link.start() catch {};
-}
-
-/// Called by renderer.Thread when it exits the main loop.
-pub fn loopExit(self: *Metal) void {
- // If we don't support a display link we have no work to do.
- if (comptime DisplayLink == void) return;
-
- // Stop our display link. If this fails its okay it just means
- // that we either never started it or the view its attached to
- // is gone which is fine.
- const display_link = self.display_link orelse return;
- display_link.stop() catch {};
-}
-
-fn displayLinkCallback(
- _: *macos.video.DisplayLink,
- ud: ?*xev.Async,
-) void {
- const draw_now = ud orelse return;
- draw_now.notify() catch |err| {
- log.err("error notifying draw_now err={}", .{err});
- };
-}
-
-/// Mark the full screen as dirty so that we redraw everything.
-pub fn markDirty(self: *Metal) void {
- // This is how we force a full rebuild with metal.
- self.cells_viewport = null;
-}
-
-/// Called when we get an updated display ID for our display link.
-pub fn setMacOSDisplayID(self: *Metal, id: u32) !void {
- if (comptime DisplayLink == void) return;
- const display_link = self.display_link orelse return;
- log.info("updating display link display id={}", .{id});
- display_link.setCurrentCGDisplay(id) catch |err| {
- log.warn("error setting display link display id err={}", .{err});
- };
-}
-
-/// True if our renderer has animations so that a higher frequency
-/// timer is used.
-pub fn hasAnimations(self: *const Metal) bool {
- return self.custom_shader_state != null;
-}
-
-/// True if our renderer is using vsync. If true, the renderer or apprt
-/// is responsible for triggering draw_now calls to the render thread. That
-/// is the only way to trigger a drawFrame.
-pub fn hasVsync(self: *const Metal) bool {
- if (comptime DisplayLink == void) return false;
- const display_link = self.display_link orelse return false;
- return display_link.isRunning();
-}
-
-/// Callback when the focus changes for the terminal this is rendering.
-///
-/// Must be called on the render thread.
-pub fn setFocus(self: *Metal, focus: bool) !void {
- self.focused = focus;
-
- // If we're not focused, then we want to stop the display link
- // because it is a waste of resources and we can move to pure
- // change-driven updates.
- if (comptime DisplayLink != void) link: {
- const display_link = self.display_link orelse break :link;
- if (focus) {
- display_link.start() catch {};
- } else {
- display_link.stop() catch {};
- }
- }
-}
-
-/// Callback when the window is visible or occluded.
-///
-/// Must be called on the render thread.
-pub fn setVisible(self: *Metal, visible: bool) void {
- // If we're not visible, then we want to stop the display link
- // because it is a waste of resources and we can move to pure
- // change-driven updates.
- if (comptime DisplayLink != void) link: {
- const display_link = self.display_link orelse break :link;
- if (visible and self.focused) {
- display_link.start() catch {};
- } else {
- display_link.stop() catch {};
- }
- }
-}
-
-/// Set the new font grid.
-///
-/// Must be called on the render thread.
-pub fn setFontGrid(self: *Metal, grid: *font.SharedGrid) void {
- // Update our grid
- self.font_grid = grid;
-
- // Update all our textures so that they sync on the next frame.
- // We can modify this without a lock because the GPU does not
- // touch this data.
- for (&self.gpu_state.frames) |*frame| {
- frame.grayscale_modified = 0;
- frame.color_modified = 0;
- }
-
- // Get our metrics from the grid. This doesn't require a lock because
- // the metrics are never recalculated.
- const metrics = grid.metrics;
- self.grid_metrics = metrics;
-
- // Reset our shaper cache. If our font changed (not just the size) then
- // the data in the shaper cache may be invalid and cannot be used, so we
- // always clear the cache just in case.
- const font_shaper_cache = font.ShaperCache.init();
- self.font_shaper_cache.deinit(self.alloc);
- self.font_shaper_cache = font_shaper_cache;
-
- // Run a screen size update since this handles a lot of our uniforms
- // that are grid size dependent and changing the font grid can change
- // the grid size.
- //
- // If the screen size isn't set, it will be eventually so that'll call
- // the setScreenSize automatically.
- self.setScreenSize(self.size) catch |err| {
- // The setFontGrid function can't fail but resizing our cell
- // buffer definitely can fail. If it does, our renderer is probably
- // screwed but let's just log it and continue until we can figure
- // out a better way to handle this.
- log.err("error resizing cells buffer err={}", .{err});
+/// Get the current size of the runtime surface.
+pub fn surfaceSize(self: *const Metal) !struct { width: u32, height: u32 } {
+ const bounds = self.layer.layer.getProperty(graphics.Rect, "bounds");
+ const scale = self.layer.layer.getProperty(f64, "contentsScale");
+ return .{
+ .width = @intFromFloat(bounds.size.width * scale),
+ .height = @intFromFloat(bounds.size.height * scale),
};
-
- // Reset our viewport to force a rebuild, since `setScreenSize` only
- // does this when the number of cells changes, which isn't guaranteed.
- self.cells_viewport = null;
}
-/// Update the frame data.
-pub fn updateFrame(
- self: *Metal,
- surface: *apprt.Surface,
- state: *renderer.State,
- cursor_blink_visible: bool,
-) !void {
- _ = surface;
-
- // Data we extract out of the critical area.
- const Critical = struct {
- bg: terminal.color.RGB,
- screen: terminal.Screen,
- screen_type: terminal.ScreenType,
- mouse: renderer.State.Mouse,
- preedit: ?renderer.State.Preedit,
- cursor_style: ?renderer.CursorStyle,
- color_palette: terminal.color.Palette,
- viewport_pin: terminal.Pin,
-
- /// If true, rebuild the full screen.
- full_rebuild: bool,
- };
-
- // Update all our data as tightly as possible within the mutex.
- var critical: Critical = critical: {
- // const start = try std.time.Instant.now();
- // const start_micro = std.time.microTimestamp();
- // defer {
- // const end = std.time.Instant.now() catch unreachable;
- // // "[updateFrame critical time] <START us>\t<TIME_TAKEN us>"
- // std.log.err("[updateFrame critical time] {}\t{}", .{start_micro, end.since(start) / std.time.ns_per_us});
- // }
-
- state.mutex.lock();
- defer state.mutex.unlock();
-
- // If we're in a synchronized output state, we pause all rendering.
- if (state.terminal.modes.get(.synchronized_output)) {
- log.debug("synchronized output started, skipping render", .{});
- return;
- }
-
- // Swap bg/fg if the terminal is reversed
- const bg = self.background_color orelse self.default_background_color;
- const fg = self.foreground_color orelse self.default_foreground_color;
- defer {
- if (self.background_color) |*c| {
- c.* = bg;
- } else {
- self.default_background_color = bg;
- }
-
- if (self.foreground_color) |*c| {
- c.* = fg;
- } else {
- self.default_foreground_color = fg;
- }
- }
-
- if (state.terminal.modes.get(.reverse_colors)) {
- if (self.background_color) |*c| {
- c.* = fg;
- } else {
- self.default_background_color = fg;
- }
-
- if (self.foreground_color) |*c| {
- c.* = bg;
- } else {
- self.default_foreground_color = bg;
- }
- }
-
- // Get the viewport pin so that we can compare it to the current.
- const viewport_pin = state.terminal.screen.pages.pin(.{ .viewport = .{} }).?;
-
- // We used to share terminal state, but we've since learned through
- // analysis that it is faster to copy the terminal state than to
- // hold the lock while rebuilding GPU cells.
- var screen_copy = try state.terminal.screen.clone(
- self.alloc,
- .{ .viewport = .{} },
- null,
- );
- errdefer screen_copy.deinit();
-
- // Whether to draw our cursor or not.
- const cursor_style = if (state.terminal.flags.password_input)
- .lock
+/// Initialize a new render target which can be presented by this API.
+pub fn initTarget(self: *const Metal, width: usize, height: usize) !Target {
+ return Target.init(.{
+ .device = self.device,
+ // Using an `*_srgb` pixel format makes Metal gamma encode the pixels
+ // written to it *after* blending, which means we get linear alpha
+ // blending rather than gamma-incorrect blending.
+ .pixel_format = if (self.blending.isLinear())
+ .bgra8unorm_srgb
else
- renderer.cursorStyle(
- state,
- self.focused,
- cursor_blink_visible,
- );
-
- // Get our preedit state
- const preedit: ?renderer.State.Preedit = preedit: {
- if (cursor_style == null) break :preedit null;
- const p = state.preedit orelse break :preedit null;
- break :preedit try p.clone(self.alloc);
- };
- errdefer if (preedit) |p| p.deinit(self.alloc);
-
- // If we have Kitty graphics data, we enter a SLOW SLOW SLOW path.
- // We only do this if the Kitty image state is dirty meaning only if
- // it changes.
- //
- // If we have any virtual references, we must also rebuild our
- // kitty state on every frame because any cell change can move
- // an image.
- if (state.terminal.screen.kitty_images.dirty or
- self.image_virtual)
- {
- try self.prepKittyGraphics(state.terminal);
- }
-
- // If we have any terminal dirty flags set then we need to rebuild
- // the entire screen. This can be optimized in the future.
- const full_rebuild: bool = rebuild: {
- {
- const Int = @typeInfo(terminal.Terminal.Dirty).@"struct".backing_integer.?;
- const v: Int = @bitCast(state.terminal.flags.dirty);
- if (v > 0) break :rebuild true;
- }
- {
- const Int = @typeInfo(terminal.Screen.Dirty).@"struct".backing_integer.?;
- const v: Int = @bitCast(state.terminal.screen.dirty);
- if (v > 0) break :rebuild true;
- }
-
- // If our viewport changed then we need to rebuild the entire
- // screen because it means we scrolled. If we have no previous
- // viewport then we must rebuild.
- const prev_viewport = self.cells_viewport orelse break :rebuild true;
- if (!prev_viewport.eql(viewport_pin)) break :rebuild true;
-
- break :rebuild false;
- };
-
- // Reset the dirty flags in the terminal and screen. We assume
- // that our rebuild will be successful since so we optimize for
- // success and reset while we hold the lock. This is much easier
- // than coordinating row by row or as changes are persisted.
- state.terminal.flags.dirty = .{};
- state.terminal.screen.dirty = .{};
- {
- var it = state.terminal.screen.pages.pageIterator(
- .right_down,
- .{ .screen = .{} },
- null,
- );
- while (it.next()) |chunk| {
- var dirty_set = chunk.node.data.dirtyBitSet();
- dirty_set.unsetAll();
- }
- }
-
- break :critical .{
- .bg = self.background_color orelse self.default_background_color,
- .screen = screen_copy,
- .screen_type = state.terminal.active_screen,
- .mouse = state.mouse,
- .preedit = preedit,
- .cursor_style = cursor_style,
- .color_palette = state.terminal.color_palette.colors,
- .viewport_pin = viewport_pin,
- .full_rebuild = full_rebuild,
- };
- };
- defer {
- critical.screen.deinit();
- if (critical.preedit) |p| p.deinit(self.alloc);
- }
-
- // Build our GPU cells
- try self.rebuildCells(
- critical.full_rebuild,
- &critical.screen,
- critical.screen_type,
- critical.mouse,
- critical.preedit,
- critical.cursor_style,
- &critical.color_palette,
- );
-
- // Notify our shaper we're done for the frame. For some shapers like
- // CoreText this triggers off-thread cleanup logic.
- self.font_shaper.endFrame();
-
- // Update our viewport pin
- self.cells_viewport = critical.viewport_pin;
-
- // Update our background color
- self.uniforms.bg_color = .{
- critical.bg.r,
- critical.bg.g,
- critical.bg.b,
- @intFromFloat(@round(self.config.background_opacity * 255.0)),
- };
-
- // Update the background color on our layer
- //
- // TODO: Is this expensive? Should we be checking if our
- // bg color has changed first before doing this work?
- {
- const color = graphics.c.CGColorCreate(
- @ptrCast(self.terminal_colorspace),
- &[4]f64{
- @as(f64, @floatFromInt(critical.bg.r)) / 255.0,
- @as(f64, @floatFromInt(critical.bg.g)) / 255.0,
- @as(f64, @floatFromInt(critical.bg.b)) / 255.0,
- self.config.background_opacity,
- },
- );
- defer graphics.c.CGColorRelease(color);
-
- // We use a CATransaction so that Core Animation knows that we
- // updated the background color property. Otherwise it behaves
- // weird, not updating the color until we resize.
- const CATransaction = objc.getClass("CATransaction").?;
- CATransaction.msgSend(void, "begin", .{});
- defer CATransaction.msgSend(void, "commit", .{});
-
- self.layer.setProperty("backgroundColor", color);
- }
-
- // Go through our images and see if we need to setup any textures.
- {
- var image_it = self.images.iterator();
- while (image_it.next()) |kv| {
- switch (kv.value_ptr.image) {
- .ready => {},
-
- .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.gpu_state.device,
- self.gpu_state.default_storage_mode,
- ),
-
- .unload_pending,
- .unload_replace,
- .unload_ready,
- => {
- kv.value_ptr.image.deinit(self.alloc);
- self.images.removeByPtr(kv.key_ptr);
- },
- }
- }
- }
-}
-
-/// Draw the frame to the screen.
-pub fn drawFrame(self: *Metal, surface: *apprt.Surface) !void {
- _ = surface;
-
- // If we have no cells rebuilt we can usually skip drawing since there
- // is no changed data. However, if we have active animations we still
- // need to draw so that we can update the time uniform and render the
- // changes.
- if (!self.cells_rebuilt and !self.hasAnimations()) return;
- self.cells_rebuilt = false;
-
- // Wait for a frame to be available.
- const frame = self.gpu_state.nextFrame();
- errdefer self.gpu_state.releaseFrame();
- // log.debug("drawing frame index={}", .{self.gpu_state.frame_index});
-
- // Setup our frame data
- try frame.uniforms.sync(self.gpu_state.device, &.{self.uniforms});
- try frame.cells_bg.sync(self.gpu_state.device, self.cells.bg_cells);
- const fg_count = try frame.cells.syncFromArrayLists(self.gpu_state.device, self.cells.fg_rows.lists);
-
- // If we have custom shaders, update the animation time.
- if (self.custom_shader_state) |*state| {
- const now = std.time.Instant.now() catch state.first_frame_time;
- const since_ns: f32 = @floatFromInt(now.since(state.first_frame_time));
- const delta_ns: f32 = @floatFromInt(now.since(state.last_frame_time));
- state.uniforms.time = since_ns / std.time.ns_per_s;
- state.uniforms.time_delta = delta_ns / std.time.ns_per_s;
- state.last_frame_time = now;
- }
-
- // @autoreleasepool {}
- const pool = objc.AutoreleasePool.init();
- defer pool.deinit();
-
- // Get our drawable (CAMetalDrawable)
- const drawable = self.layer.msgSend(objc.Object, objc.sel("nextDrawable"), .{});
-
- // Get our screen texture. If we don't have a dedicated screen texture
- // then we just use the drawable texture.
- const screen_texture = if (self.custom_shader_state) |state|
- state.back_texture
- else tex: {
- const texture = drawable.msgSend(objc.c.id, objc.sel("texture"), .{});
- break :tex objc.Object.fromId(texture);
- };
-
- // If our font atlas changed, sync the texture data
- texture: {
- const modified = self.font_grid.atlas_grayscale.modified.load(.monotonic);
- if (modified <= frame.grayscale_modified) break :texture;
- self.font_grid.lock.lockShared();
- defer self.font_grid.lock.unlockShared();
- frame.grayscale_modified = self.font_grid.atlas_grayscale.modified.load(.monotonic);
- try syncAtlasTexture(
- self.gpu_state.device,
- &self.font_grid.atlas_grayscale,
- &frame.grayscale,
- self.gpu_state.default_storage_mode,
- );
- }
- texture: {
- const modified = self.font_grid.atlas_color.modified.load(.monotonic);
- if (modified <= frame.color_modified) break :texture;
- self.font_grid.lock.lockShared();
- defer self.font_grid.lock.unlockShared();
- frame.color_modified = self.font_grid.atlas_color.modified.load(.monotonic);
- try syncAtlasTexture(
- self.gpu_state.device,
- &self.font_grid.atlas_color,
- &frame.color,
- self.gpu_state.default_storage_mode,
- );
- }
-
- // Command buffer (MTLCommandBuffer)
- const buffer = self.gpu_state.queue.msgSend(objc.Object, objc.sel("commandBuffer"), .{});
-
- {
- // MTLRenderPassDescriptor
- const desc = desc: {
- const MTLRenderPassDescriptor = objc.getClass("MTLRenderPassDescriptor").?;
- const desc = MTLRenderPassDescriptor.msgSend(
- objc.Object,
- objc.sel("renderPassDescriptor"),
- .{},
- );
-
- // Set our color attachment to be our drawable surface.
- const attachments = objc.Object.fromId(desc.getProperty(?*anyopaque, "colorAttachments"));
- {
- const attachment = attachments.msgSend(
- objc.Object,
- objc.sel("objectAtIndexedSubscript:"),
- .{@as(c_ulong, 0)},
- );
-
- attachment.setProperty("loadAction", @intFromEnum(mtl.MTLLoadAction.clear));
- attachment.setProperty("storeAction", @intFromEnum(mtl.MTLStoreAction.store));
- attachment.setProperty("texture", screen_texture.value);
- attachment.setProperty("clearColor", mtl.MTLClearColor{
- .red = 0.0,
- .green = 0.0,
- .blue = 0.0,
- .alpha = 0.0,
- });
- }
-
- break :desc desc;
- };
-
- // MTLRenderCommandEncoder
- const encoder = buffer.msgSend(
- objc.Object,
- objc.sel("renderCommandEncoderWithDescriptor:"),
- .{desc.value},
- );
- defer encoder.msgSend(void, objc.sel("endEncoding"), .{});
-
- // Draw background images first
- try self.drawImagePlacements(encoder, frame, self.image_placements.items[0..self.image_bg_end]);
-
- // Then draw background cells
- try self.drawCellBgs(encoder, frame);
-
- // Then draw images under text
- try self.drawImagePlacements(encoder, frame, self.image_placements.items[self.image_bg_end..self.image_text_end]);
-
- // Then draw fg cells
- try self.drawCellFgs(encoder, frame, fg_count);
-
- // Then draw remaining images
- try self.drawImagePlacements(encoder, frame, self.image_placements.items[self.image_text_end..]);
- }
-
- // If we have custom shaders, then we render them.
- if (self.custom_shader_state) |*state| {
- // MTLRenderPassDescriptor
- const desc = desc: {
- const MTLRenderPassDescriptor = objc.getClass("MTLRenderPassDescriptor").?;
- const desc = MTLRenderPassDescriptor.msgSend(
- objc.Object,
- objc.sel("renderPassDescriptor"),
- .{},
- );
-
- break :desc desc;
- };
-
- // Prepare our color attachment (output).
- const attachments = objc.Object.fromId(desc.getProperty(?*anyopaque, "colorAttachments"));
- const attachment = attachments.msgSend(
- objc.Object,
- objc.sel("objectAtIndexedSubscript:"),
- .{@as(c_ulong, 0)},
- );
- attachment.setProperty("loadAction", @intFromEnum(mtl.MTLLoadAction.clear));
- attachment.setProperty("storeAction", @intFromEnum(mtl.MTLStoreAction.store));
- attachment.setProperty("clearColor", mtl.MTLClearColor{
- .red = 0,
- .green = 0,
- .blue = 0,
- .alpha = 1,
- });
-
- const post_len = self.shaders.post_pipelines.len;
-
- for (self.shaders.post_pipelines[0 .. post_len - 1]) |pipeline| {
- // Set our color attachment to be our front texture.
- attachment.setProperty("texture", state.front_texture.value);
-
- // MTLRenderCommandEncoder
- const encoder = buffer.msgSend(
- objc.Object,
- objc.sel("renderCommandEncoderWithDescriptor:"),
- .{desc.value},
- );
- defer encoder.msgSend(void, objc.sel("endEncoding"), .{});
-
- // Draw shader
- try self.drawPostShader(encoder, pipeline, state);
- // Swap the front and back textures.
- state.swap();
- }
-
- // Draw the final shader directly to the drawable.
- {
- // Set our color attachment to be our drawable.
- //
- // Texture is a property of CAMetalDrawable but if you run
- // Ghostty in XCode in debug mode it returns a CaptureMTLDrawable
- // which ironically doesn't implement CAMetalDrawable as a
- // property so we just send a message.
- const texture = drawable.msgSend(objc.c.id, objc.sel("texture"), .{});
- attachment.setProperty("texture", texture);
-
- // MTLRenderCommandEncoder
- const encoder = buffer.msgSend(
- objc.Object,
- objc.sel("renderCommandEncoderWithDescriptor:"),
- .{desc.value},
- );
- defer encoder.msgSend(void, objc.sel("endEncoding"), .{});
-
- try self.drawPostShader(
- encoder,
- self.shaders.post_pipelines[post_len - 1],
- state,
- );
- }
- }
-
- buffer.msgSend(void, objc.sel("presentDrawable:"), .{drawable.value});
-
- // Create our block to register for completion updates. This is used
- // so we can detect failures. The block is deallocated by the objC
- // runtime on success.
- const block = try CompletionBlock.init(.{ .self = self }, &bufferCompleted);
- errdefer block.deinit();
- buffer.msgSend(void, objc.sel("addCompletedHandler:"), .{block.context});
-
- buffer.msgSend(void, objc.sel("commit"), .{});
+ .bgra8unorm,
+ .storage_mode = self.default_storage_mode,
+ .width = width,
+ .height = height,
+ });
}
-/// This is the block type used for the addCompletedHandler call.back.
-const CompletionBlock = objc.Block(struct { self: *Metal }, .{
- objc.c.id, // MTLCommandBuffer
-}, void);
-
-/// This is the callback called by the CompletionBlock invocation for
-/// addCompletedHandler.
-///
-/// Note: this is USUALLY called on a separate thread because the renderer
-/// thread and the Apple event loop threads are usually different. Therefore,
-/// we need to be mindful of thread safety here.
-fn bufferCompleted(
- block: *const CompletionBlock.Context,
- buffer_id: objc.c.id,
-) callconv(.c) void {
- const self = block.self;
- const buffer = objc.Object.fromId(buffer_id);
-
- // Get our command buffer status. If it is anything other than error
- // then we don't care and just return right away. We're looking for
- // errors so that we can log them.
- const status = buffer.getProperty(mtl.MTLCommandBufferStatus, "status");
- const health: Health = switch (status) {
- .@"error" => .unhealthy,
- else => .healthy,
- };
-
- // If our health value hasn't changed, then we do nothing. We don't
- // do a cmpxchg here because strict atomicity isn't important.
- if (self.health.load(.seq_cst) != health) {
- self.health.store(health, .seq_cst);
-
- // Our health value changed, so we notify the surface so that it
- // can do something about it.
- _ = self.surface_mailbox.push(.{
- .renderer_health = health,
- }, .{ .forever = {} });
+/// Present the provided target.
+pub inline fn present(self: *Metal, target: Target, sync: bool) !void {
+ if (sync) {
+ self.layer.setSurfaceSync(target.surface);
+ } else {
+ try self.layer.setSurface(target.surface);
}
-
- // Always release our semaphore
- self.gpu_state.releaseFrame();
}
-fn drawPostShader(
- self: *Metal,
- encoder: objc.Object,
- pipeline: objc.Object,
- state: *const CustomShaderState,
-) !void {
+/// Present the last presented target again. (noop for Metal)
+pub inline fn presentLastTarget(self: *Metal) !void {
_ = self;
-
- // Use our custom shader pipeline
- encoder.msgSend(
- void,
- objc.sel("setRenderPipelineState:"),
- .{pipeline.value},
- );
-
- // Set our sampler
- encoder.msgSend(
- void,
- objc.sel("setFragmentSamplerState:atIndex:"),
- .{ state.sampler.sampler.value, @as(c_ulong, 0) },
- );
-
- // Set our uniforms
- encoder.msgSend(
- void,
- objc.sel("setFragmentBytes:length:atIndex:"),
- .{
- @as(*const anyopaque, @ptrCast(&state.uniforms)),
- @as(c_ulong, @sizeOf(@TypeOf(state.uniforms))),
- @as(c_ulong, 0),
- },
- );
-
- // Screen texture
- encoder.msgSend(
- void,
- objc.sel("setFragmentTexture:atIndex:"),
- .{
- state.back_texture.value,
- @as(c_ulong, 0),
- },
- );
-
- // Draw!
- encoder.msgSend(
- void,
- objc.sel("drawPrimitives:vertexStart:vertexCount:"),
- .{
- @intFromEnum(mtl.MTLPrimitiveType.triangle),
- @as(c_ulong, 0),
- @as(c_ulong, 3),
- },
- );
-}
-
-fn drawImagePlacements(
- self: *Metal,
- encoder: objc.Object,
- frame: *const FrameState,
- placements: []const mtl_image.Placement,
-) !void {
- if (placements.len == 0) return;
-
- // Use our image shader pipeline
- encoder.msgSend(
- void,
- objc.sel("setRenderPipelineState:"),
- .{self.shaders.image_pipeline.value},
- );
-
- // Set our uniforms
- encoder.msgSend(
- void,
- objc.sel("setVertexBuffer:offset:atIndex:"),
- .{ frame.uniforms.buffer.value, @as(c_ulong, 0), @as(c_ulong, 1) },
- );
- encoder.msgSend(
- void,
- objc.sel("setFragmentBuffer:offset:atIndex:"),
- .{ frame.uniforms.buffer.value, @as(c_ulong, 0), @as(c_ulong, 1) },
- );
-
- for (placements) |placement| {
- try self.drawImagePlacement(encoder, placement);
- }
}
-fn drawImagePlacement(
- self: *Metal,
- encoder: objc.Object,
- p: mtl_image.Placement,
-) !void {
- // Look up the image
- const image = self.images.get(p.image_id) orelse {
- log.warn("image not found for placement image_id={}", .{p.image_id});
- return;
- };
-
- // Get the texture
- const texture = switch (image.image) {
- .ready => |t| t,
- else => {
- log.warn("image not ready for placement image_id={}", .{p.image_id});
- return;
+/// Returns the options to use when constructing buffers.
+pub inline fn bufferOptions(self: Metal) bufferpkg.Options {
+ return .{
+ .device = self.device,
+ .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,
},
};
-
- // Create our vertex buffer, which is always exactly one item.
- // future(mitchellh): we can group rendering multiple instances of a single image
- const Buffer = mtl_buffer.Buffer(mtl_shaders.Image);
- var buf = try Buffer.initFill(self.gpu_state.device, &.{.{
- .grid_pos = .{
- @as(f32, @floatFromInt(p.x)),
- @as(f32, @floatFromInt(p.y)),
- },
-
- .cell_offset = .{
- @as(f32, @floatFromInt(p.cell_offset_x)),
- @as(f32, @floatFromInt(p.cell_offset_y)),
- },
-
- .source_rect = .{
- @as(f32, @floatFromInt(p.source_x)),
- @as(f32, @floatFromInt(p.source_y)),
- @as(f32, @floatFromInt(p.source_width)),
- @as(f32, @floatFromInt(p.source_height)),
- },
-
- .dest_size = .{
- @as(f32, @floatFromInt(p.width)),
- @as(f32, @floatFromInt(p.height)),
- },
- }}, .{
- // Indicate that the CPU writes to this resource but never reads it.
- .cpu_cache_mode = .write_combined,
- .storage_mode = self.gpu_state.default_storage_mode,
- });
- defer buf.deinit();
-
- // Set our buffer
- encoder.msgSend(
- void,
- objc.sel("setVertexBuffer:offset:atIndex:"),
- .{ buf.buffer.value, @as(c_ulong, 0), @as(c_ulong, 0) },
- );
-
- // Set our texture
- encoder.msgSend(
- void,
- objc.sel("setVertexTexture:atIndex:"),
- .{
- texture.value,
- @as(c_ulong, 0),
- },
- );
- encoder.msgSend(
- void,
- objc.sel("setFragmentTexture:atIndex:"),
- .{
- texture.value,
- @as(c_ulong, 0),
- },
- );
-
- // Draw!
- encoder.msgSend(
- void,
- objc.sel("drawIndexedPrimitives:indexCount:indexType:indexBuffer:indexBufferOffset:instanceCount:"),
- .{
- @intFromEnum(mtl.MTLPrimitiveType.triangle),
- @as(c_ulong, 6),
- @intFromEnum(mtl.MTLIndexType.uint16),
- self.gpu_state.instance.buffer.value,
- @as(c_ulong, 0),
- @as(c_ulong, 1),
- },
- );
-
- // log.debug("drawImagePlacement: {}", .{p});
-}
-
-/// Draw the cell backgrounds.
-fn drawCellBgs(
- self: *Metal,
- encoder: objc.Object,
- frame: *const FrameState,
-) !void {
- // Use our shader pipeline
- encoder.msgSend(
- void,
- objc.sel("setRenderPipelineState:"),
- .{self.shaders.cell_bg_pipeline.value},
- );
-
- // Set our buffers
- encoder.msgSend(
- void,
- objc.sel("setVertexBuffer:offset:atIndex:"),
- .{ frame.uniforms.buffer.value, @as(c_ulong, 0), @as(c_ulong, 1) },
- );
- encoder.msgSend(
- void,
- objc.sel("setFragmentBuffer:offset:atIndex:"),
- .{ frame.cells_bg.buffer.value, @as(c_ulong, 0), @as(c_ulong, 0) },
- );
- encoder.msgSend(
- void,
- objc.sel("setFragmentBuffer:offset:atIndex:"),
- .{ frame.uniforms.buffer.value, @as(c_ulong, 0), @as(c_ulong, 1) },
- );
-
- encoder.msgSend(
- void,
- objc.sel("drawPrimitives:vertexStart:vertexCount:"),
- .{
- @intFromEnum(mtl.MTLPrimitiveType.triangle),
- @as(c_ulong, 0),
- @as(c_ulong, 3),
- },
- );
}
-/// Draw the cell foregrounds using the text shader.
-fn drawCellFgs(
- self: *Metal,
- encoder: objc.Object,
- frame: *const FrameState,
- len: usize,
-) !void {
- // This triggers an assertion in the Metal API if we try to draw
- // with an instance count of 0 so just bail.
- if (len == 0) return;
-
- // Use our shader pipeline
- encoder.msgSend(
- void,
- objc.sel("setRenderPipelineState:"),
- .{self.shaders.cell_text_pipeline.value},
- );
-
- // Set our buffers
- encoder.msgSend(
- void,
- objc.sel("setVertexBuffer:offset:atIndex:"),
- .{ frame.cells.buffer.value, @as(c_ulong, 0), @as(c_ulong, 0) },
- );
- encoder.msgSend(
- void,
- objc.sel("setVertexBuffer:offset:atIndex:"),
- .{ frame.uniforms.buffer.value, @as(c_ulong, 0), @as(c_ulong, 1) },
- );
- encoder.msgSend(
- void,
- objc.sel("setVertexBuffer:offset:atIndex:"),
- .{ frame.cells_bg.buffer.value, @as(c_ulong, 0), @as(c_ulong, 2) },
- );
- encoder.msgSend(
- void,
- objc.sel("setFragmentTexture:atIndex:"),
- .{ frame.grayscale.value, @as(c_ulong, 0) },
- );
- encoder.msgSend(
- void,
- objc.sel("setFragmentTexture:atIndex:"),
- .{ frame.color.value, @as(c_ulong, 1) },
- );
- encoder.msgSend(
- void,
- objc.sel("setFragmentBuffer:offset:atIndex:"),
- .{ frame.uniforms.buffer.value, @as(c_ulong, 0), @as(c_ulong, 2) },
- );
+pub const instanceBufferOptions = bufferOptions;
+pub const uniformBufferOptions = bufferOptions;
+pub const fgBufferOptions = bufferOptions;
+pub const bgBufferOptions = bufferOptions;
+pub const imageBufferOptions = bufferOptions;
+pub const bgImageBufferOptions = bufferOptions;
- encoder.msgSend(
- void,
- objc.sel("drawIndexedPrimitives:indexCount:indexType:indexBuffer:indexBufferOffset:instanceCount:"),
- .{
- @intFromEnum(mtl.MTLPrimitiveType.triangle),
- @as(c_ulong, 6),
- @intFromEnum(mtl.MTLIndexType.uint16),
- self.gpu_state.instance.buffer.value,
- @as(c_ulong, 0),
- @as(c_ulong, len),
+/// Returns the options to use when constructing textures.
+pub inline fn textureOptions(self: Metal) Texture.Options {
+ return .{
+ .device = self.device,
+ // Using an `*_srgb` pixel format makes Metal gamma encode the pixels
+ // written to it *after* blending, which means we get linear alpha
+ // blending rather than gamma-incorrect blending.
+ .pixel_format = if (self.blending.isLinear())
+ .bgra8unorm_srgb
+ else
+ .bgra8unorm,
+ .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,
},
- );
-}
-
-/// 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.
-fn prepKittyGraphics(
- self: *Metal,
- t: *terminal.Terminal,
-) !void {
- const storage = &t.screen.kitty_images;
- defer storage.dirty = false;
-
- // We always clear our previous placements no matter what because
- // we rebuild them from scratch.
- self.image_placements.clearRetainingCapacity();
- self.image_virtual = false;
-
- // Go through our known images and if there are any that are no longer
- // in use then mark them to be freed.
- //
- // This never conflicts with the below because a placement can't
- // reference an image that doesn't exist.
- {
- var it = self.images.iterator();
- while (it.next()) |kv| {
- if (storage.imageById(kv.key_ptr.*) == null) {
- kv.value_ptr.image.markForUnload();
- }
- }
- }
-
- // The top-left and bottom-right corners of our viewport in screen
- // points. This lets us determine offsets and containment of placements.
- const top = t.screen.pages.getTopLeft(.viewport);
- const bot = t.screen.pages.getBottomRight(.viewport).?;
- 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.
- var it = storage.placements.iterator();
- while (it.next()) |kv| {
- const p = kv.value_ptr;
-
- // Special logic based on location
- switch (p.location) {
- .pin => {},
- .virtual => {
- // We need to mark virtual placements on our renderer so that
- // we know to rebuild in more scenarios since cell changes can
- // now trigger placement changes.
- self.image_virtual = true;
-
- // We also continue out because virtual placements are
- // only triggered by the unicode placeholder, not by the
- // placement itself.
- continue;
- },
- }
-
- // Get the image for the placement
- const image = storage.imageById(kv.key_ptr.image_id) orelse {
- log.warn(
- "missing image for placement, ignoring image_id={}",
- .{kv.key_ptr.image_id},
- );
- continue;
- };
-
- try self.prepKittyPlacement(t, top_y, bot_y, &image, p);
- }
-
- // If we have virtual placements then we need to scan for placeholders.
- if (self.image_virtual) {
- var v_it = terminal.kitty.graphics.unicode.placementIterator(top, bot);
- while (v_it.next()) |virtual_p| try self.prepKittyVirtualPlacement(
- t,
- &virtual_p,
- );
- }
-
- // Sort the placements by their Z value.
- std.mem.sortUnstable(
- mtl_image.Placement,
- self.image_placements.items,
- {},
- struct {
- fn lessThan(
- ctx: void,
- lhs: mtl_image.Placement,
- rhs: mtl_image.Placement,
- ) bool {
- _ = ctx;
- return lhs.z < rhs.z or (lhs.z == rhs.z and lhs.image_id < rhs.image_id);
- }
- }.lessThan,
- );
-
- // 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;
- for (self.image_placements.items, 0..) |p, i| {
- if (bg_end == null and p.z >= bg_limit) {
- bg_end = @intCast(i);
- }
- if (text_end == null and p.z >= 0) {
- text_end = @intCast(i);
- }
- }
-
- self.image_bg_end = bg_end orelse 0;
- self.image_text_end = text_end orelse self.image_bg_end;
-}
-
-fn prepKittyVirtualPlacement(
- self: *Metal,
- t: *terminal.Terminal,
- p: *const terminal.kitty.graphics.unicode.Placement,
-) !void {
- const storage = &t.screen.kitty_images;
- const image = storage.imageById(p.image_id) orelse {
- log.warn(
- "missing image for virtual placement, ignoring image_id={}",
- .{p.image_id},
- );
- return;
- };
-
- const rp = p.renderPlacement(
- storage,
- &image,
- self.grid_metrics.cell_width,
- self.grid_metrics.cell_height,
- ) catch |err| {
- log.warn("error rendering virtual placement err={}", .{err});
- return;
};
-
- // If our placement is zero sized then we don't do anything.
- if (rp.dest_width == 0 or rp.dest_height == 0) return;
-
- const viewport: terminal.point.Point = t.screen.pages.pointFromPin(
- .viewport,
- rp.top_left,
- ) orelse {
- // This is unreachable with virtual placements because we should
- // only ever be looking at virtual placements that are in our
- // viewport in the renderer and virtual placements only ever take
- // up one row.
- unreachable;
- };
-
- // Send our image to the GPU and store the placement for rendering.
- try self.prepKittyImage(&image);
- try self.image_placements.append(self.alloc, .{
- .image_id = image.id,
- .x = @intCast(rp.top_left.x),
- .y = @intCast(viewport.viewport.y),
- .z = -1,
- .width = rp.dest_width,
- .height = rp.dest_height,
- .cell_offset_x = rp.offset_x,
- .cell_offset_y = rp.offset_y,
- .source_x = rp.source_x,
- .source_y = rp.source_y,
- .source_width = rp.source_width,
- .source_height = rp.source_height,
- });
}
-fn prepKittyPlacement(
- self: *Metal,
- t: *terminal.Terminal,
- top_y: u32,
- bot_y: u32,
- image: *const terminal.kitty.graphics.Image,
- p: *const terminal.kitty.graphics.ImageStorage.Placement,
-) !void {
- // Get the rect for the placement. If this placement doesn't have
- // a rect then its virtual or something so skip it.
- const rect = p.rect(image.*, t) orelse return;
-
- // This is expensive but necessary.
- const img_top_y = t.screen.pages.pointFromPin(.screen, rect.top_left).?.screen.y;
- const img_bot_y = t.screen.pages.pointFromPin(.screen, rect.bottom_right).?.screen.y;
-
- // If the selection isn't within our viewport then skip it.
- if (img_top_y > bot_y) return;
- if (img_bot_y < top_y) return;
+/// 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,
- // 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
- // account the rows / columns specified by the placement.
- const dest_size = p.calculatedSize(image.*, t);
-
- // Calculate the source rectangle
- const source_x = @min(image.width, p.source_x);
- const source_y = @min(image.height, p.source_y);
- const source_width = if (p.source_width > 0)
- @min(image.width - source_x, p.source_width)
- else
- image.width;
- const source_height = if (p.source_height > 0)
- @min(image.height - source_y, p.source_height)
- else
- image.height;
-
- // Get the viewport-relative Y position of the placement.
- const y_pos: i32 = @as(i32, @intCast(img_top_y)) - @as(i32, @intCast(top_y));
-
- // Accumulate the placement
- if (dest_size.width > 0 and dest_size.height > 0) {
- try self.image_placements.append(self.alloc, .{
- .image_id = image.id,
- .x = @intCast(rect.top_left.x),
- .y = y_pos,
- .z = p.z,
- .width = dest_size.width,
- .height = dest_size.height,
- .cell_offset_x = p.x_offset,
- .cell_offset_y = p.y_offset,
- .source_x = source_x,
- .source_y = source_y,
- .source_width = source_width,
- .source_height = source_height,
- });
- }
-}
-
-fn prepKittyImage(
- self: *Metal,
- image: *const terminal.kitty.graphics.Image,
-) !void {
- // If this image exists and its transmit time is the same we assume
- // it is the identical image so we don't need to send it to the GPU.
- const gop = try self.images.getOrPut(self.alloc, image.id);
- if (gop.found_existing and
- gop.value_ptr.transmit_time.order(image.transmit_time) == .eq)
- {
- return;
- }
-
- // Copy the data into the pending state.
- const data = try self.alloc.dupe(u8, image.data);
- errdefer self.alloc.free(data);
-
- // Store it in the map
- const pending: Image.Pending = .{
- .width = image.width,
- .height = image.height,
- .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
- };
-
- if (!gop.found_existing) {
- gop.value_ptr.* = .{
- .image = new_image,
- .transmit_time = undefined,
+ 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,
};
- } else {
- try gop.value_ptr.image.markForReplace(
- self.alloc,
- new_image,
- );
}
+};
- gop.value_ptr.transmit_time = image.transmit_time;
-}
-
-/// Update the configuration.
-pub fn changeConfig(self: *Metal, config: *DerivedConfig) !void {
- // We always redo the font shaper in case font features changed. We
- // could check to see if there was an actual config change but this is
- // easier and rare enough to not cause performance issues.
- {
- var font_shaper = try font.Shaper.init(self.alloc, .{
- .features = config.font_features.items,
- });
- errdefer font_shaper.deinit();
- self.font_shaper.deinit();
- self.font_shaper = font_shaper;
- }
-
- // We also need to reset the shaper cache so shaper info
- // from the previous font isn't re-used for the new font.
- const font_shaper_cache = font.ShaperCache.init();
- self.font_shaper_cache.deinit(self.alloc);
- self.font_shaper_cache = font_shaper_cache;
-
- // Set our new minimum contrast
- self.uniforms.min_contrast = config.min_contrast;
-
- // Set our new color space and blending
- self.uniforms.use_display_p3 = config.colorspace == .@"display-p3";
- self.uniforms.use_linear_blending = config.blending.isLinear();
- self.uniforms.use_linear_correction = config.blending == .@"linear-corrected";
-
- // Set our new colors
- self.default_background_color = config.background;
- self.default_foreground_color = config.foreground;
- self.default_cursor_color = if (!config.cursor_invert) config.cursor_color else null;
- self.cursor_invert = config.cursor_invert;
-
- // Update our layer's opaqueness and display sync in case they changed.
- {
- // We use a CATransaction so that Core Animation knows that we
- // updated the opaque property. Otherwise it behaves weird, not
- // properly going from opaque to transparent unless we resize.
- const CATransaction = objc.getClass("CATransaction").?;
- CATransaction.msgSend(void, "begin", .{});
- defer CATransaction.msgSend(void, "commit", .{});
-
- self.layer.setProperty("opaque", config.background_opacity >= 1);
- self.layer.setProperty("displaySyncEnabled", config.vsync);
- }
-
- // Update our terminal colorspace if it changed
- if (self.config.colorspace != config.colorspace) {
- const terminal_colorspace = try graphics.ColorSpace.createNamed(
- switch (config.colorspace) {
- .@"display-p3" => .displayP3,
- .srgb => .sRGB,
- },
- );
- errdefer terminal_colorspace.release();
- self.terminal_colorspace.release();
- self.terminal_colorspace = terminal_colorspace;
- }
-
- const old_blending = self.config.blending;
- const old_custom_shaders = self.config.custom_shaders;
-
- self.config.deinit();
- self.config = config.*;
-
- // Reset our viewport to force a rebuild, in case of a font change.
- self.cells_viewport = null;
-
- // We reinitialize our shaders if our
- // blending or custom shaders changed.
- if (old_blending != config.blending or
- !old_custom_shaders.equal(config.custom_shaders))
- {
- self.deinitShaders();
- try self.initShaders();
- // We call setScreenSize to reinitialize
- // the textures used for custom shaders.
- if (self.custom_shader_state != null) {
- try self.setScreenSize(self.size);
- }
- // And we update our layer's pixel format appropriately.
- self.layer.setProperty(
- "pixelFormat",
- if (config.blending.isLinear())
- @intFromEnum(mtl.MTLPixelFormat.bgra8unorm_srgb)
- else
- @intFromEnum(mtl.MTLPixelFormat.bgra8unorm),
- );
- }
-}
-
-/// Resize the screen.
-pub fn setScreenSize(
- self: *Metal,
- size: renderer.Size,
-) !void {
- // Store our sizes
- self.size = size;
- const grid_size = size.grid();
- const terminal_size = size.terminal();
-
- // Blank space around the grid.
- const blank: renderer.Padding = size.screen.blankPadding(
- size.padding,
- grid_size,
- size.cell,
- ).add(size.padding);
-
- var padding_extend = self.uniforms.padding_extend;
- switch (self.config.padding_color) {
- .extend => {
- // If padding extension is enabled, we extend left and right always
- // because there is no downside to this. Up/down is dependent
- // on some heuristics (see rebuildCells).
- padding_extend.left = true;
- padding_extend.right = true;
- },
-
- .@"extend-always" => {
- padding_extend.up = true;
- padding_extend.down = true;
- padding_extend.left = true;
- padding_extend.right = true;
- },
-
- .background => {
- // Otherwise, disable all padding extension.
- padding_extend = .{};
- },
- }
-
- // Set the size of the drawable surface to the bounds
- self.layer.setProperty("drawableSize", graphics.Size{
- .width = @floatFromInt(size.screen.width),
- .height = @floatFromInt(size.screen.height),
- });
-
- // Setup our uniforms
- const old = self.uniforms;
- self.uniforms = .{
- .projection_matrix = math.ortho2d(
- -1 * @as(f32, @floatFromInt(size.padding.left)),
- @floatFromInt(terminal_size.width + size.padding.right),
- @floatFromInt(terminal_size.height + size.padding.bottom),
- -1 * @as(f32, @floatFromInt(size.padding.top)),
- ),
- .cell_size = .{
- @floatFromInt(self.grid_metrics.cell_width),
- @floatFromInt(self.grid_metrics.cell_height),
- },
- .grid_size = .{
- grid_size.columns,
- grid_size.rows,
- },
- .grid_padding = .{
- @floatFromInt(blank.top),
- @floatFromInt(blank.right),
- @floatFromInt(blank.bottom),
- @floatFromInt(blank.left),
+/// 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,
},
- .padding_extend = padding_extend,
- .min_contrast = old.min_contrast,
- .cursor_pos = old.cursor_pos,
- .cursor_color = old.cursor_color,
- .bg_color = old.bg_color,
- .cursor_wide = old.cursor_wide,
- .use_display_p3 = old.use_display_p3,
- .use_linear_blending = old.use_linear_blending,
- .use_linear_correction = old.use_linear_correction,
};
-
- // Reset our cell contents if our grid size has changed.
- if (!self.cells.size.equals(grid_size)) {
- try self.cells.resize(self.alloc, grid_size);
-
- // Reset our viewport to force a rebuild
- self.cells_viewport = null;
- }
-
- // If we have custom shaders then we update the state
- if (self.custom_shader_state) |*state| {
- // Only free our previous texture if this isn't our first
- // time setting the custom shader state.
- if (state.uniforms.resolution[0] > 0) {
- state.front_texture.release();
- state.back_texture.release();
- }
-
- state.uniforms.resolution = .{
- @floatFromInt(size.screen.width),
- @floatFromInt(size.screen.height),
- 1,
- };
-
- state.front_texture = texture: {
- // This texture is the size of our drawable but supports being a
- // render target AND reading so that the custom shaders can read from it.
- const desc = init: {
- const Class = objc.getClass("MTLTextureDescriptor").?;
- const id_alloc = Class.msgSend(objc.Object, objc.sel("alloc"), .{});
- const id_init = id_alloc.msgSend(objc.Object, objc.sel("init"), .{});
- break :init id_init;
- };
- desc.setProperty(
- "pixelFormat",
- // Using an `*_srgb` pixel format makes Metal gamma encode
- // the pixels written to it *after* blending, which means
- // we get linear alpha blending rather than gamma-incorrect
- // blending.
- if (self.config.blending.isLinear())
- @intFromEnum(mtl.MTLPixelFormat.bgra8unorm_srgb)
- else
- @intFromEnum(mtl.MTLPixelFormat.bgra8unorm),
- );
- desc.setProperty("width", @as(c_ulong, @intCast(size.screen.width)));
- desc.setProperty("height", @as(c_ulong, @intCast(size.screen.height)));
- desc.setProperty(
- "usage",
- @intFromEnum(mtl.MTLTextureUsage.render_target) |
- @intFromEnum(mtl.MTLTextureUsage.shader_read) |
- @intFromEnum(mtl.MTLTextureUsage.shader_write),
- );
-
- // If we fail to create the texture, then we just don't have a screen
- // texture and our custom shaders won't run.
- const id = self.gpu_state.device.msgSend(
- ?*anyopaque,
- objc.sel("newTextureWithDescriptor:"),
- .{desc},
- ) orelse return error.MetalFailed;
-
- break :texture objc.Object.fromId(id);
- };
-
- state.back_texture = texture: {
- // This texture is the size of our drawable but supports being a
- // render target AND reading so that the custom shaders can read from it.
- const desc = init: {
- const Class = objc.getClass("MTLTextureDescriptor").?;
- const id_alloc = Class.msgSend(objc.Object, objc.sel("alloc"), .{});
- const id_init = id_alloc.msgSend(objc.Object, objc.sel("init"), .{});
- break :init id_init;
- };
- desc.setProperty(
- "pixelFormat",
- // Using an `*_srgb` pixel format makes Metal gamma encode
- // the pixels written to it *after* blending, which means
- // we get linear alpha blending rather than gamma-incorrect
- // blending.
- if (self.config.blending.isLinear())
- @intFromEnum(mtl.MTLPixelFormat.bgra8unorm_srgb)
- else
- @intFromEnum(mtl.MTLPixelFormat.bgra8unorm),
- );
- desc.setProperty("width", @as(c_ulong, @intCast(size.screen.width)));
- desc.setProperty("height", @as(c_ulong, @intCast(size.screen.height)));
- desc.setProperty(
- "usage",
- @intFromEnum(mtl.MTLTextureUsage.render_target) |
- @intFromEnum(mtl.MTLTextureUsage.shader_read) |
- @intFromEnum(mtl.MTLTextureUsage.shader_write),
- );
-
- // If we fail to create the texture, then we just don't have a screen
- // texture and our custom shaders won't run.
- const id = self.gpu_state.device.msgSend(
- ?*anyopaque,
- objc.sel("newTextureWithDescriptor:"),
- .{desc},
- ) orelse return error.MetalFailed;
-
- break :texture objc.Object.fromId(id);
- };
- }
-
- log.debug("screen size size={}", .{size});
-}
-
-/// Convert the terminal state to GPU cells stored in CPU memory. These
-/// are then synced to the GPU in the next frame. This only updates CPU
-/// memory and doesn't touch the GPU.
-fn rebuildCells(
- self: *Metal,
- rebuild: bool,
- screen: *terminal.Screen,
- screen_type: terminal.ScreenType,
- mouse: renderer.State.Mouse,
- preedit: ?renderer.State.Preedit,
- cursor_style_: ?renderer.CursorStyle,
- color_palette: *const terminal.color.Palette,
-) !void {
- // const start = try std.time.Instant.now();
- // const start_micro = std.time.microTimestamp();
- // defer {
- // const end = std.time.Instant.now() catch unreachable;
- // // "[rebuildCells time] <START us>\t<TIME_TAKEN us>"
- // std.log.warn("[rebuildCells time] {}\t{}", .{start_micro, end.since(start) / std.time.ns_per_us});
- // }
-
- _ = screen_type; // we might use this again later so not deleting it yet
-
- // Create an arena for all our temporary allocations while rebuilding
- var arena = ArenaAllocator.init(self.alloc);
- defer arena.deinit();
- const arena_alloc = arena.allocator();
-
- // Create our match set for the links.
- var link_match_set: link.MatchSet = if (mouse.point) |mouse_pt| try self.config.links.matchSet(
- arena_alloc,
- screen,
- mouse_pt,
- mouse.mods,
- ) else .{};
-
- // Determine our x/y range for preedit. We don't want to render anything
- // here because we will render the preedit separately.
- const preedit_range: ?struct {
- y: terminal.size.CellCountInt,
- x: [2]terminal.size.CellCountInt,
- cp_offset: usize,
- } = if (preedit) |preedit_v| preedit: {
- const range = preedit_v.range(screen.cursor.x, screen.pages.cols - 1);
- break :preedit .{
- .y = screen.cursor.y,
- .x = .{ range.start, range.end },
- .cp_offset = range.cp_offset,
- };
- } else null;
-
- if (rebuild) {
- // If we are doing a full rebuild, then we clear the entire cell buffer.
- self.cells.reset();
-
- // We also reset our padding extension depending on the screen type
- switch (self.config.padding_color) {
- .background => {},
-
- // For extension, assume we are extending in all directions.
- // For "extend" this may be disabled due to heuristics below.
- .extend, .@"extend-always" => {
- self.uniforms.padding_extend = .{
- .up = true,
- .down = true,
- .left = true,
- .right = true,
- };
- },
- }
- }
-
- // We rebuild the cells row-by-row because we
- // do font shaping and dirty tracking by row.
- var row_it = screen.pages.rowIterator(.left_up, .{ .viewport = .{} }, null);
- // If our cell contents buffer is shorter than the screen viewport,
- // we render the rows that fit, starting from the bottom. If instead
- // the viewport is shorter than the cell contents buffer, we align
- // the top of the viewport with the top of the contents buffer.
- var y: terminal.size.CellCountInt = @min(
- screen.pages.rows,
- self.cells.size.rows,
- );
- while (row_it.next()) |row| {
- // The viewport may have more rows than our cell contents,
- // so we need to break from the loop early if we hit y = 0.
- if (y == 0) break;
-
- y -= 1;
-
- if (!rebuild) {
- // Only rebuild if we are doing a full rebuild or this row is dirty.
- if (!row.isDirty()) continue;
-
- // Clear the cells if the row is dirty
- self.cells.clear(y);
- }
-
- // True if we want to do font shaping around the cursor. We want to
- // do font shaping as long as the cursor is enabled.
- const shape_cursor = screen.viewportIsBottom() and
- y == screen.cursor.y;
-
- // We need to get this row's selection if there is one for proper
- // run splitting.
- const row_selection = sel: {
- const sel = screen.selection orelse break :sel null;
- const pin = screen.pages.pin(.{ .viewport = .{ .y = y } }) orelse
- break :sel null;
- break :sel sel.containedRow(screen, pin) orelse null;
- };
-
- // On primary screen, we still apply vertical padding extension
- // under certain conditions we feel are safe. This helps make some
- // scenarios look better while avoiding scenarios we know do NOT look
- // good.
- switch (self.config.padding_color) {
- // These already have the correct values set above.
- .background, .@"extend-always" => {},
-
- // Apply heuristics for padding extension.
- .extend => if (y == 0) {
- self.uniforms.padding_extend.up = !row.neverExtendBg(
- color_palette,
- self.background_color orelse self.default_background_color,
- );
- } else if (y == self.cells.size.rows - 1) {
- self.uniforms.padding_extend.down = !row.neverExtendBg(
- color_palette,
- self.background_color orelse self.default_background_color,
- );
- },
- }
-
- // 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 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;
-
- const row_cells_all = row.cells(.all);
-
- // If our viewport is wider than our cell contents buffer,
- // we still only process cells up to the width of the buffer.
- const row_cells = row_cells_all[0..@min(row_cells_all.len, self.cells.size.columns)];
-
- for (row_cells, 0..) |*cell, x| {
- // If this cell falls within our preedit range then we
- // skip this because preedits are setup separately.
- if (preedit_range) |range| preedit: {
- // We're not on the preedit line, no actions necessary.
- if (range.y != y) break :preedit;
- // We're before the preedit range, no actions necessary.
- if (x < range.x[0]) break :preedit;
- // We're in the preedit range, skip this cell.
- if (x <= range.x[1]) continue;
- // After exiting the preedit range we need to catch
- // the run position up because of the missed cells.
- // In all other cases, no action is necessary.
- if (x != range.x[1] + 1) break :preedit;
-
- // Step the run iterator until we find a run that ends
- // after the current cell, which will be the soonest run
- // that might contain glyphs for our cell.
- while (shaper_run) |run| {
- if (run.offset + run.cells > x) break;
- shaper_run = try run_iter.next(self.alloc);
- shaper_cells = null;
- shaper_cells_i = 0;
- }
-
- const run = shaper_run orelse break :preedit;
-
- // If we haven't shaped this run, do so now.
- shaper_cells = shaper_cells orelse
- // Try to read the cells from the shaping cache if we can.
- self.font_shaper_cache.get(run) orelse
- cache: {
- // Otherwise we have to shape them.
- const cells = try self.font_shaper.shape(run);
-
- // Try to cache them. If caching fails for any reason we
- // continue because it is just a performance optimization,
- // not a correctness issue.
- self.font_shaper_cache.put(
- self.alloc,
- run,
- cells,
- ) catch |err| {
- log.warn(
- "error caching font shaping results err={}",
- .{err},
- );
- };
-
- // The cells we get from direct shaping are always owned
- // by the shaper and valid until the next shaping call so
- // we can safely use them.
- break :cache cells;
- };
-
- // Advance our index until we reach or pass
- // our current x position in the shaper cells.
- while (shaper_cells.?[shaper_cells_i].x < x) {
- shaper_cells_i += 1;
- }
- }
-
- const wide = cell.wide;
-
- const style = row.style(cell);
-
- const cell_pin: terminal.Pin = cell: {
- var copy = row;
- copy.x = @intCast(x);
- break :cell copy;
- };
-
- // True if this cell is selected
- const selected: bool = if (screen.selection) |sel|
- sel.contains(screen, .{
- .node = row.node,
- .y = row.y,
- .x = @intCast(
- // Spacer tails should show the selection
- // state of the wide cell they belong to.
- if (wide == .spacer_tail)
- x -| 1
- else
- x,
- ),
- })
- else
- false;
-
- const bg_style = style.bg(cell, color_palette);
- const fg_style = style.fg(color_palette, self.config.bold_is_bright) orelse self.foreground_color orelse self.default_foreground_color;
-
- // The final background color for the cell.
- const bg = bg: {
- if (selected) {
- break :bg if (self.config.invert_selection_fg_bg)
- if (style.flags.inverse)
- // Cell is selected with invert selection fg/bg
- // enabled, and the cell has the inverse style
- // flag, so they cancel out and we get the normal
- // bg color.
- bg_style
- else
- // If it doesn't have the inverse style
- // flag then we use the fg color instead.
- fg_style
- else
- // If we don't have invert selection fg/bg set then we
- // just use the selection background if set, otherwise
- // the default fg color.
- break :bg self.config.selection_background orelse self.foreground_color orelse self.default_foreground_color;
- }
-
- // Not selected
- break :bg if (style.flags.inverse != isCovering(cell.codepoint()))
- // Two cases cause us to invert (use the fg color as the bg)
- // - The "inverse" style flag.
- // - A "covering" glyph; we use fg for bg in that case to
- // help make sure that padding extension works correctly.
- // If one of these is true (but not the other)
- // then we use the fg style color for the bg.
- fg_style
- else
- // Otherwise they cancel out.
- bg_style;
- };
-
- const fg = fg: {
- if (selected and !self.config.invert_selection_fg_bg) {
- // If we don't have invert selection fg/bg set
- // then we just use the selection foreground if
- // set, otherwise the default bg color.
- break :fg self.config.selection_foreground orelse self.background_color orelse self.default_background_color;
- }
-
- // Whether we need to use the bg color as our fg color:
- // - Cell is inverted and not selected
- // - Cell is selected and not inverted
- // Note: if selected then invert sel fg / bg must be
- // false since we separately handle it if true above.
- break :fg if (style.flags.inverse != selected)
- bg_style orelse self.background_color orelse self.default_background_color
- else
- fg_style;
- };
-
- // Foreground alpha for this cell.
- const alpha: u8 = if (style.flags.faint) 175 else 255;
-
- // Set the cell's background color.
- {
- const rgb = bg orelse self.background_color orelse self.default_background_color;
-
- // Determine our background alpha. If we have transparency configured
- // then this is dynamic depending on some situations. This is all
- // in an attempt to make transparency look the best for various
- // situations. See inline comments.
- 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;
-
- // Cells that are reversed should be fully opaque.
- 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;
- }
-
- // Otherwise, we use the configured background opacity.
- break :bg_alpha @intFromFloat(@round(self.config.background_opacity * 255.0));
- };
-
- self.cells.bgCell(y, x).* = .{
- rgb.r, rgb.g, rgb.b, bg_alpha,
- };
- }
-
- // If the invisible flag is set on this cell then we
- // don't need to render any foreground elements, so
- // we just skip all glyphs with this x coordinate.
- //
- // NOTE: This behavior matches xterm. Some other terminal
- // emulators, e.g. Alacritty, still render text decorations
- // and only make the text itself invisible. The decision
- // has been made here to match xterm's behavior for this.
- if (style.flags.invisible) {
- continue;
- }
-
- // Give links a single underline, unless they already have
- // an underline, in which case use a double underline to
- // distinguish them.
- const underline: terminal.Attribute.Underline = if (link_match_set.contains(screen, cell_pin))
- if (style.flags.underline == .single)
- .double
- else
- .single
- else
- style.flags.underline;
-
- // We draw underlines first so that they layer underneath text.
- // This improves readability when a colored underline is used
- // which intersects parts of the text (descenders).
- if (underline != .none) self.addUnderline(
- @intCast(x),
- @intCast(y),
- underline,
- style.underlineColor(color_palette) orelse fg,
- alpha,
- ) catch |err| {
- log.warn(
- "error adding underline to cell, will be invalid x={} y={}, err={}",
- .{ x, y, err },
- );
- };
-
- if (style.flags.overline) self.addOverline(@intCast(x), @intCast(y), fg, alpha) catch |err| {
- log.warn(
- "error adding overline to cell, will be invalid x={} y={}, err={}",
- .{ x, y, err },
- );
- };
-
- // If we're at or past the end of our shaper run then
- // we need to get the next run from the run iterator.
- if (shaper_cells != null and shaper_cells_i >= shaper_cells.?.len) {
- shaper_run = try run_iter.next(self.alloc);
- shaper_cells = null;
- shaper_cells_i = 0;
- }
-
- if (shaper_run) |run| glyphs: {
- // If we haven't shaped this run yet, do so.
- shaper_cells = shaper_cells orelse
- // Try to read the cells from the shaping cache if we can.
- self.font_shaper_cache.get(run) orelse
- cache: {
- // Otherwise we have to shape them.
- const cells = try self.font_shaper.shape(run);
-
- // Try to cache them. If caching fails for any reason we
- // continue because it is just a performance optimization,
- // not a correctness issue.
- self.font_shaper_cache.put(
- self.alloc,
- run,
- cells,
- ) catch |err| {
- log.warn(
- "error caching font shaping results err={}",
- .{err},
- );
- };
-
- // The cells we get from direct shaping are always owned
- // by the shaper and valid until the next shaping call so
- // we can safely use them.
- break :cache cells;
- };
-
- const cells = shaper_cells orelse break :glyphs;
-
- // If there are no shaper cells for this run, ignore it.
- // This can occur for runs of empty cells, and is fine.
- if (cells.len == 0) break :glyphs;
-
- // If we encounter a shaper cell to the left of the current
- // cell then we have some problems. This logic relies on x
- // position monotonically increasing.
- assert(cells[shaper_cells_i].x >= x);
-
- // NOTE: An assumption is made here that a single cell will never
- // be present in more than one shaper run. If that assumption is
- // violated, this logic breaks.
-
- while (shaper_cells_i < cells.len and cells[shaper_cells_i].x == x) : ({
- shaper_cells_i += 1;
- }) {
- self.addGlyph(
- @intCast(x),
- @intCast(y),
- cell_pin,
- cells[shaper_cells_i],
- shaper_run.?,
- fg,
- alpha,
- ) catch |err| {
- log.warn(
- "error adding glyph to cell, will be invalid x={} y={}, err={}",
- .{ x, y, err },
- );
- };
- }
- }
-
- // Finally, draw a strikethrough if necessary.
- if (style.flags.strikethrough) self.addStrikethrough(
- @intCast(x),
- @intCast(y),
- fg,
- alpha,
- ) catch |err| {
- log.warn(
- "error adding strikethrough to cell, will be invalid x={} y={}, err={}",
- .{ x, y, err },
- );
- };
- }
- }
-
- // Setup our cursor rendering information.
- cursor: {
- // By default, we don't handle cursor inversion on the shader.
- self.cells.setCursor(null);
- self.uniforms.cursor_pos = .{
- std.math.maxInt(u16),
- std.math.maxInt(u16),
- };
-
- // If we have preedit text, we don't setup a cursor
- if (preedit != null) break :cursor;
-
- // Prepare the cursor cell contents.
- const style = cursor_style_ orelse break :cursor;
- const cursor_color = self.cursor_color orelse self.default_cursor_color orelse color: {
- if (self.cursor_invert) {
- // Use the foreground color from the cell under the cursor, if any.
- const sty = screen.cursor.page_pin.style(screen.cursor.page_cell);
- break :color if (sty.flags.inverse)
- // If the cell is reversed, use background color instead.
- (sty.bg(screen.cursor.page_cell, color_palette) orelse self.background_color orelse self.default_background_color)
- else
- (sty.fg(color_palette, self.config.bold_is_bright) orelse self.foreground_color orelse self.default_foreground_color);
- } else {
- break :color self.foreground_color orelse self.default_foreground_color;
- }
- };
-
- self.addCursor(screen, style, cursor_color);
-
- // If the cursor is visible then we set our uniforms.
- if (style == .block and screen.viewportIsBottom()) {
- const wide = screen.cursor.page_cell.wide;
-
- self.uniforms.cursor_pos = .{
- // If we are a spacer tail of a wide cell, our cursor needs
- // to move back one cell. The saturate is to ensure we don't
- // overflow but this shouldn't happen with well-formed input.
- switch (wide) {
- .narrow, .spacer_head, .wide => screen.cursor.x,
- .spacer_tail => screen.cursor.x -| 1,
- },
- screen.cursor.y,
- };
-
- self.uniforms.cursor_wide = switch (wide) {
- .narrow, .spacer_head => false,
- .wide, .spacer_tail => true,
- };
-
- const uniform_color = if (self.cursor_invert) blk: {
- // Use the background color from the cell under the cursor, if any.
- const sty = screen.cursor.page_pin.style(screen.cursor.page_cell);
- break :blk if (sty.flags.inverse)
- // If the cell is reversed, use foreground color instead.
- (sty.fg(color_palette, self.config.bold_is_bright) orelse self.foreground_color orelse self.default_foreground_color)
- else
- (sty.bg(screen.cursor.page_cell, color_palette) orelse self.background_color orelse self.default_background_color);
- } else if (self.config.cursor_text) |txt|
- txt
- else
- self.background_color orelse self.default_background_color;
-
- self.uniforms.cursor_color = .{
- uniform_color.r,
- uniform_color.g,
- uniform_color.b,
- 255,
- };
- }
- }
-
- // Setup our preedit text.
- if (preedit) |preedit_v| {
- const range = preedit_range.?;
- var x = range.x[0];
- for (preedit_v.codepoints[range.cp_offset..]) |cp| {
- self.addPreeditCell(cp, .{ .x = x, .y = range.y }) catch |err| {
- log.warn("error building preedit cell, will be invalid x={} y={}, err={}", .{
- x,
- range.y,
- err,
- });
- };
-
- x += if (cp.wide) 2 else 1;
- }
- }
-
- // Update that our cells rebuilt
- self.cells_rebuilt = true;
-
- // Log some things
- // log.debug("rebuildCells complete cached_runs={}", .{
- // self.font_shaper_cache.count(),
- // });
}
-/// Add an underline decoration to the specified cell
-fn addUnderline(
- self: *Metal,
- x: terminal.size.CellCountInt,
- y: terminal.size.CellCountInt,
- style: terminal.Attribute.Underline,
- color: terminal.color.RGB,
- alpha: u8,
-) !void {
- const sprite: font.Sprite = switch (style) {
- .none => unreachable,
- .single => .underline,
- .double => .underline_double,
- .dotted => .underline_dotted,
- .dashed => .underline_dashed,
- .curly => .underline_curly,
+/// Initializes a Texture suitable for the provided font atlas.
+pub fn initAtlasTexture(
+ self: *const Metal,
+ atlas: *const font.Atlas,
+) Texture.Error!Texture {
+ const pixel_format: mtl.MTLPixelFormat = switch (atlas.format) {
+ .grayscale => .r8unorm,
+ .bgra => .bgra8unorm_srgb,
+ else => @panic("unsupported atlas format for Metal texture"),
};
- const render = try self.font_grid.renderGlyph(
- self.alloc,
- font.sprite_index,
- @intFromEnum(sprite),
+ return try Texture.init(
.{
- .cell_width = 1,
- .grid_metrics = self.grid_metrics,
- },
- );
-
- try self.cells.add(self.alloc, .underline, .{
- .mode = .fg,
- .grid_pos = .{ @intCast(x), @intCast(y) },
- .constraint_width = 1,
- .color = .{ color.r, color.g, color.b, alpha },
- .glyph_pos = .{ render.glyph.atlas_x, render.glyph.atlas_y },
- .glyph_size = .{ render.glyph.width, render.glyph.height },
- .bearings = .{
- @intCast(render.glyph.offset_x),
- @intCast(render.glyph.offset_y),
- },
- });
-}
-
-/// Add a overline decoration to the specified cell
-fn addOverline(
- self: *Metal,
- x: terminal.size.CellCountInt,
- y: terminal.size.CellCountInt,
- color: terminal.color.RGB,
- alpha: u8,
-) !void {
- const render = try self.font_grid.renderGlyph(
- self.alloc,
- font.sprite_index,
- @intFromEnum(font.Sprite.overline),
- .{
- .cell_width = 1,
- .grid_metrics = self.grid_metrics,
- },
- );
-
- try self.cells.add(self.alloc, .overline, .{
- .mode = .fg,
- .grid_pos = .{ @intCast(x), @intCast(y) },
- .constraint_width = 1,
- .color = .{ color.r, color.g, color.b, alpha },
- .glyph_pos = .{ render.glyph.atlas_x, render.glyph.atlas_y },
- .glyph_size = .{ render.glyph.width, render.glyph.height },
- .bearings = .{
- @intCast(render.glyph.offset_x),
- @intCast(render.glyph.offset_y),
- },
- });
-}
-
-/// Add a strikethrough decoration to the specified cell
-fn addStrikethrough(
- self: *Metal,
- x: terminal.size.CellCountInt,
- y: terminal.size.CellCountInt,
- color: terminal.color.RGB,
- alpha: u8,
-) !void {
- const render = try self.font_grid.renderGlyph(
- self.alloc,
- font.sprite_index,
- @intFromEnum(font.Sprite.strikethrough),
- .{
- .cell_width = 1,
- .grid_metrics = self.grid_metrics,
- },
- );
-
- try self.cells.add(self.alloc, .strikethrough, .{
- .mode = .fg,
- .grid_pos = .{ @intCast(x), @intCast(y) },
- .constraint_width = 1,
- .color = .{ color.r, color.g, color.b, alpha },
- .glyph_pos = .{ render.glyph.atlas_x, render.glyph.atlas_y },
- .glyph_size = .{ render.glyph.width, render.glyph.height },
- .bearings = .{
- @intCast(render.glyph.offset_x),
- @intCast(render.glyph.offset_y),
- },
- });
-}
-
-// Add a glyph to the specified cell.
-fn addGlyph(
- self: *Metal,
- x: terminal.size.CellCountInt,
- y: terminal.size.CellCountInt,
- cell_pin: terminal.Pin,
- shaper_cell: font.shape.Cell,
- shaper_run: font.shape.TextRun,
- color: terminal.color.RGB,
- alpha: u8,
-) !void {
- const rac = cell_pin.rowAndCell();
- const cell = rac.cell;
-
- // Render
- const render = try self.font_grid.renderGlyph(
- self.alloc,
- shaper_run.font_index,
- shaper_cell.glyph_index,
- .{
- .grid_metrics = self.grid_metrics,
- .thicken = self.config.font_thicken,
- .thicken_strength = self.config.font_thicken_strength,
- },
- );
-
- // If the glyph is 0 width or height, it will be invisible
- // when drawn, so don't bother adding it to the buffer.
- if (render.glyph.width == 0 or render.glyph.height == 0) {
- return;
- }
-
- const mode: mtl_shaders.CellText.Mode = switch (try fgMode(
- render.presentation,
- cell_pin,
- )) {
- .normal => .fg,
- .color => .fg_color,
- .constrained => .fg_constrained,
- .powerline => .fg_powerline,
- };
-
- try self.cells.add(self.alloc, .text, .{
- .mode = mode,
- .grid_pos = .{ @intCast(x), @intCast(y) },
- .constraint_width = cell.gridWidth(),
- .color = .{ color.r, color.g, color.b, alpha },
- .glyph_pos = .{ render.glyph.atlas_x, render.glyph.atlas_y },
- .glyph_size = .{ render.glyph.width, render.glyph.height },
- .bearings = .{
- @intCast(render.glyph.offset_x + shaper_cell.x_offset),
- @intCast(render.glyph.offset_y + shaper_cell.y_offset),
- },
- });
-}
-
-fn addCursor(
- self: *Metal,
- screen: *terminal.Screen,
- cursor_style: renderer.CursorStyle,
- cursor_color: terminal.color.RGB,
-) void {
- // Add the cursor. We render the cursor over the wide character if
- // we're on the wide character tail.
- const wide, const x = cell: {
- // The cursor goes over the screen cursor position.
- const cell = screen.cursor.page_cell;
- if (cell.wide != .spacer_tail or screen.cursor.x == 0)
- break :cell .{ cell.wide == .wide, screen.cursor.x };
-
- // If we're part of a wide character, we move the cursor back to
- // the actual character.
- const prev_cell = screen.cursorCellLeft(1);
- break :cell .{ prev_cell.wide == .wide, screen.cursor.x - 1 };
- };
-
- const alpha: u8 = if (!self.focused) 255 else alpha: {
- const alpha = 255 * self.config.cursor_opacity;
- break :alpha @intFromFloat(@ceil(alpha));
- };
-
- const render = switch (cursor_style) {
- .block,
- .block_hollow,
- .bar,
- .underline,
- => render: {
- const sprite: font.Sprite = switch (cursor_style) {
- .block => .cursor_rect,
- .block_hollow => .cursor_hollow_rect,
- .bar => .cursor_bar,
- .underline => .underline,
- .lock => unreachable,
- };
-
- break :render self.font_grid.renderGlyph(
- self.alloc,
- font.sprite_index,
- @intFromEnum(sprite),
- .{
- .cell_width = if (wide) 2 else 1,
- .grid_metrics = self.grid_metrics,
- },
- ) catch |err| {
- log.warn("error rendering cursor glyph err={}", .{err});
- return;
- };
- },
-
- .lock => self.font_grid.renderCodepoint(
- self.alloc,
- 0xF023, // lock symbol
- .regular,
- .text,
- .{
- .cell_width = if (wide) 2 else 1,
- .grid_metrics = self.grid_metrics,
+ .device = self.device,
+ .pixel_format = pixel_format,
+ .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,
},
- ) catch |err| {
- log.warn("error rendering cursor glyph err={}", .{err});
- return;
- } orelse {
- // This should never happen because we embed nerd
- // fonts so we just log and return instead of fallback.
- log.warn("failed to find lock symbol for cursor codepoint=0xF023", .{});
- return;
},
- };
-
- self.cells.setCursor(.{
- .mode = .cursor,
- .grid_pos = .{ x, screen.cursor.y },
- .color = .{ cursor_color.r, cursor_color.g, cursor_color.b, alpha },
- .glyph_pos = .{ render.glyph.atlas_x, render.glyph.atlas_y },
- .glyph_size = .{ render.glyph.width, render.glyph.height },
- .bearings = .{
- @intCast(render.glyph.offset_x),
- @intCast(render.glyph.offset_y),
+ atlas.size,
+ atlas.size,
+ null,
+ );
+}
+
+/// Begin a frame.
+pub inline fn beginFrame(
+ self: *const Metal,
+ /// Once the frame has been completed, the `frameCompleted` method
+ /// on the renderer is called with the health status of the frame.
+ renderer: *Renderer,
+ /// The target is presented via the provided renderer's API when completed.
+ target: *Target,
+) !Frame {
+ return try Frame.begin(.{ .queue = self.queue }, renderer, target);
+}
+
+fn chooseDevice() error{NoMetalDevice}!objc.Object {
+ var chosen_device: ?objc.Object = null;
+
+ switch (comptime builtin.os.tag) {
+ .macos => {
+ const devices = objc.Object.fromId(mtl.MTLCopyAllDevices());
+ defer devices.release();
+
+ var iter = devices.iterate();
+ while (iter.next()) |device| {
+ // We want a GPU that’s connected to a display.
+ if (device.getProperty(bool, "isHeadless")) continue;
+ chosen_device = device;
+ // If the user has an eGPU plugged in, they probably want
+ // to use it. Otherwise, integrated GPUs are better for
+ // battery life and thermals.
+ if (device.getProperty(bool, "isRemovable") or
+ device.getProperty(bool, "isLowPower")) break;
+ }
},
- });
-}
-
-fn addPreeditCell(
- self: *Metal,
- cp: renderer.State.Preedit.Codepoint,
- coord: terminal.Coordinate,
-) !void {
- // Preedit is rendered inverted
- const bg = self.foreground_color orelse self.default_foreground_color;
- const fg = self.background_color orelse self.default_background_color;
-
- // Render the glyph for our preedit text
- const render_ = self.font_grid.renderCodepoint(
- self.alloc,
- @intCast(cp.codepoint),
- .regular,
- .text,
- .{ .grid_metrics = self.grid_metrics },
- ) catch |err| {
- log.warn("error rendering preedit glyph err={}", .{err});
- return;
- };
- const render = render_ orelse {
- log.warn("failed to find font for preedit codepoint={X}", .{cp.codepoint});
- return;
- };
-
- // Add our opaque background cell
- self.cells.bgCell(coord.y, coord.x).* = .{
- bg.r, bg.g, bg.b, 255,
- };
- if (cp.wide and coord.x < self.cells.size.columns - 1) {
- self.cells.bgCell(coord.y, coord.x + 1).* = .{
- bg.r, bg.g, bg.b, 255,
- };
- }
-
- // Add our text
- try self.cells.add(self.alloc, .text, .{
- .mode = .fg,
- .grid_pos = .{ @intCast(coord.x), @intCast(coord.y) },
- .color = .{ fg.r, fg.g, fg.b, 255 },
- .glyph_pos = .{ render.glyph.atlas_x, render.glyph.atlas_y },
- .glyph_size = .{ render.glyph.width, render.glyph.height },
- .bearings = .{
- @intCast(render.glyph.offset_x),
- @intCast(render.glyph.offset_y),
+ .ios => {
+ chosen_device = objc.Object.fromId(mtl.MTLCreateSystemDefaultDevice());
},
- });
-}
-
-/// Sync the atlas data to the given texture. This copies the bytes
-/// associated with the atlas to the given texture. If the atlas no longer
-/// fits into the texture, the texture will be resized.
-fn syncAtlasTexture(
- device: objc.Object,
- atlas: *const font.Atlas,
- texture: *objc.Object,
- /// Storage mode for the MTLTexture object
- storage_mode: mtl.MTLResourceOptions.StorageMode,
-) !void {
- const width = texture.getProperty(c_ulong, "width");
- if (atlas.size > width) {
- // Free our old texture
- texture.*.release();
-
- // Reallocate
- texture.* = try initAtlasTexture(device, atlas, storage_mode);
+ else => @compileError("unsupported target for Metal"),
}
- texture.msgSend(
- void,
- objc.sel("replaceRegion:mipmapLevel:withBytes:bytesPerRow:"),
- .{
- mtl.MTLRegion{
- .origin = .{ .x = 0, .y = 0, .z = 0 },
- .size = .{
- .width = @intCast(atlas.size),
- .height = @intCast(atlas.size),
- .depth = 1,
- },
- },
- @as(c_ulong, 0),
- @as(*const anyopaque, atlas.data.ptr),
- @as(c_ulong, atlas.format.depth() * atlas.size),
- },
- );
-}
-
-/// Initialize a MTLTexture object for the given atlas.
-fn initAtlasTexture(
- device: objc.Object,
- atlas: *const font.Atlas,
- /// Storage mode for the MTLTexture object
- storage_mode: mtl.MTLResourceOptions.StorageMode,
-) !objc.Object {
- // Determine our pixel format
- const pixel_format: mtl.MTLPixelFormat = switch (atlas.format) {
- .grayscale => .r8unorm,
- .rgba => .bgra8unorm,
- else => @panic("unsupported atlas format for Metal texture"),
- };
-
- // Create our descriptor
- const desc = init: {
- const Class = objc.getClass("MTLTextureDescriptor").?;
- const id_alloc = Class.msgSend(objc.Object, objc.sel("alloc"), .{});
- const id_init = id_alloc.msgSend(objc.Object, objc.sel("init"), .{});
- break :init id_init;
- };
-
- // Set our properties
- desc.setProperty("pixelFormat", @intFromEnum(pixel_format));
- desc.setProperty("width", @as(c_ulong, @intCast(atlas.size)));
- desc.setProperty("height", @as(c_ulong, @intCast(atlas.size)));
-
- desc.setProperty(
- "resourceOptions",
- mtl.MTLResourceOptions{
- // Indicate that the CPU writes to this resource but never reads it.
- .cpu_cache_mode = .write_combined,
- .storage_mode = storage_mode,
- },
- );
-
- // Initialize
- const id = device.msgSend(
- ?*anyopaque,
- objc.sel("newTextureWithDescriptor:"),
- .{desc},
- ) orelse return error.MetalFailed;
-
- return objc.Object.fromId(id);
-}
-
-test {
- _ = mtl_cell;
+ const device = chosen_device orelse return error.NoMetalDevice;
+ return device.retain();
}
diff --git a/src/renderer/OpenGL.zig b/src/renderer/OpenGL.zig
index d0222a390..cf195361e 100644
--- a/src/renderer/OpenGL.zig
+++ b/src/renderer/OpenGL.zig
@@ -1,450 +1,173 @@
-//! Rendering implementation for OpenGL.
+//! Graphics API wrapper for OpenGL.
pub const OpenGL = @This();
const std = @import("std");
-const builtin = @import("builtin");
-const glfw = @import("glfw");
const assert = std.debug.assert;
-const testing = std.testing;
const Allocator = std.mem.Allocator;
-const ArenaAllocator = std.heap.ArenaAllocator;
-const link = @import("link.zig");
-const isCovering = @import("cell.zig").isCovering;
-const fgMode = @import("cell.zig").fgMode;
+const builtin = @import("builtin");
+const glfw = @import("glfw");
+const gl = @import("opengl");
const shadertoy = @import("shadertoy.zig");
const apprt = @import("../apprt.zig");
-const configpkg = @import("../config.zig");
const font = @import("../font/main.zig");
-const imgui = @import("imgui");
-const renderer = @import("../renderer.zig");
-const terminal = @import("../terminal/main.zig");
-const Terminal = terminal.Terminal;
-const gl = @import("opengl");
-const math = @import("../math.zig");
-const Surface = @import("../Surface.zig");
-
-const CellProgram = @import("opengl/CellProgram.zig");
-const ImageProgram = @import("opengl/ImageProgram.zig");
-const gl_image = @import("opengl/image.zig");
-const custom = @import("opengl/custom.zig");
-const Image = gl_image.Image;
-const ImageMap = gl_image.ImageMap;
-const ImagePlacementList = std.ArrayListUnmanaged(gl_image.Placement);
-
-const log = std.log.scoped(.grid);
-
-/// The runtime can request a single-threaded draw by setting this boolean
-/// to true. In this case, the renderer.draw() call is expected to be called
-/// from the runtime.
-pub const single_threaded_draw = if (@hasDecl(apprt.Surface, "opengl_single_threaded_draw"))
- apprt.Surface.opengl_single_threaded_draw
-else
- false;
-const DrawMutex = if (single_threaded_draw) std.Thread.Mutex else void;
-const drawMutexZero: DrawMutex = if (DrawMutex == void) void{} else .{};
-
-alloc: std.mem.Allocator,
-
-/// The configuration we need derived from the main config.
-config: DerivedConfig,
-
-/// Current font metrics defining our grid.
-grid_metrics: font.Metrics,
-
-/// The size of everything.
-size: renderer.Size,
-
-/// The current set of cells to render. Each set of cells goes into
-/// a separate shader call.
-cells_bg: std.ArrayListUnmanaged(CellProgram.Cell),
-cells: std.ArrayListUnmanaged(CellProgram.Cell),
-
-/// The last viewport that we based our rebuild off of. If this changes,
-/// then we do a full rebuild of the cells. The pointer values in this pin
-/// are NOT SAFE to read because they may be modified, freed, etc from the
-/// termio thread. We treat the pointers as integers for comparison only.
-cells_viewport: ?terminal.Pin = null,
-
-/// The size of the cells list that was sent to the GPU. This is used
-/// to detect when the cells array was reallocated/resized and handle that
-/// accordingly.
-gl_cells_size: usize = 0,
-
-/// The last length of the cells that was written to the GPU. This is used to
-/// determine what data needs to be rewritten on the GPU.
-gl_cells_written: usize = 0,
-
-/// Shader program for cell rendering.
-gl_state: ?GLState = null,
-
-/// The font structures.
-font_grid: *font.SharedGrid,
-font_shaper: font.Shaper,
-font_shaper_cache: font.ShaperCache,
-texture_grayscale_modified: usize = 0,
-texture_grayscale_resized: usize = 0,
-texture_color_modified: usize = 0,
-texture_color_resized: usize = 0,
-
-/// True if the window is focused
-focused: bool,
-
-/// The foreground color set by an OSC 10 sequence. If unset then the default
-/// value from the config file is used.
-foreground_color: ?terminal.color.RGB,
-
-/// Foreground color set in the user's config file.
-default_foreground_color: terminal.color.RGB,
-
-/// The background color set by an OSC 11 sequence. If unset then the default
-/// value from the config file is used.
-background_color: ?terminal.color.RGB,
-
-/// Background color set in the user's config file.
-default_background_color: terminal.color.RGB,
-
-/// The cursor color set by an OSC 12 sequence. If unset then
-/// default_cursor_color is used.
-cursor_color: ?terminal.color.RGB,
-
-/// Default cursor color when no color is set explicitly by an OSC 12 command.
-/// This is cursor color as set in the user's config, if any. If no cursor color
-/// is set in the user's config, then the cursor color is determined by the
-/// current foreground color.
-default_cursor_color: ?terminal.color.RGB,
-
-/// When `cursor_color` is null, swap the foreground and background colors of
-/// the cell under the cursor for the cursor color. Otherwise, use the default
-/// foreground color as the cursor color.
-cursor_invert: bool,
-
-/// The mailbox for communicating with the window.
-surface_mailbox: apprt.surface.Mailbox,
-
-/// Deferred operations. This is used to apply changes to the OpenGL context.
-/// Some runtimes (GTK) do not support multi-threading so to keep our logic
-/// simple we apply all OpenGL context changes in the render() call.
-deferred_screen_size: ?SetScreenSize = null,
-deferred_font_size: ?SetFontSize = null,
-deferred_config: ?SetConfig = null,
-
-/// If we're drawing with single threaded operations
-draw_mutex: DrawMutex = drawMutexZero,
-
-/// Current background to draw. This may not match self.background if the
-/// terminal is in reversed mode.
-draw_background: terminal.color.RGB,
-
-/// Whether we're doing padding extension for vertical sides.
-padding_extend_top: bool = true,
-padding_extend_bottom: bool = true,
-
-/// The images that we may render.
-images: ImageMap = .{},
-image_placements: ImagePlacementList = .{},
-image_bg_end: u32 = 0,
-image_text_end: u32 = 0,
-image_virtual: bool = false,
-
-/// Deferred OpenGL operation to update the screen size.
-const SetScreenSize = struct {
- size: renderer.Size,
-
- fn apply(self: SetScreenSize, r: *OpenGL) !void {
- const gl_state: *GLState = if (r.gl_state) |*v|
- v
- else
- return error.OpenGLUninitialized;
-
- // Apply our padding
- const grid_size = self.size.grid();
- const terminal_size = self.size.terminal();
-
- // Blank space around the grid.
- const blank: renderer.Padding = switch (r.config.padding_color) {
- // We can use zero padding because the background color is our
- // clear color.
- .background => .{},
-
- .extend, .@"extend-always" => self.size.screen.blankPadding(
- self.size.padding,
- grid_size,
- self.size.cell,
- ).add(self.size.padding),
- };
-
- // Update our viewport for this context to be the entire window.
- // OpenGL works in pixels, so we have to use the pixel size.
- try gl.viewport(
- 0,
- 0,
- @intCast(self.size.screen.width),
- @intCast(self.size.screen.height),
- );
-
- // Update the projection uniform within our shader
- inline for (.{ "cell_program", "image_program" }) |name| {
- const program = @field(gl_state, name);
- const bind = try program.program.use();
- defer bind.unbind();
- try program.program.setUniform(
- "projection",
-
- // 2D orthographic projection with the full w/h
- math.ortho2d(
- -1 * @as(f32, @floatFromInt(self.size.padding.left)),
- @floatFromInt(terminal_size.width + self.size.padding.right),
- @floatFromInt(terminal_size.height + self.size.padding.bottom),
- -1 * @as(f32, @floatFromInt(self.size.padding.top)),
- ),
- );
- }
-
- // Setup our grid padding
- {
- const program = gl_state.cell_program;
- const bind = try program.program.use();
- defer bind.unbind();
- try program.program.setUniform(
- "grid_padding",
- @Vector(4, f32){
- @floatFromInt(blank.top),
- @floatFromInt(blank.right),
- @floatFromInt(blank.bottom),
- @floatFromInt(blank.left),
- },
- );
- try program.program.setUniform(
- "grid_size",
- @Vector(2, f32){
- @floatFromInt(grid_size.columns),
- @floatFromInt(grid_size.rows),
- },
- );
- }
-
- // Update our custom shader resolution
- if (gl_state.custom) |*custom_state| {
- try custom_state.setScreenSize(self.size);
- }
- }
-};
-
-const SetFontSize = struct {
- metrics: font.Metrics,
-
- fn apply(self: SetFontSize, r: *const OpenGL) !void {
- const gl_state = r.gl_state orelse return error.OpenGLUninitialized;
-
- inline for (.{ "cell_program", "image_program" }) |name| {
- const program = @field(gl_state, name);
- const bind = try program.program.use();
- defer bind.unbind();
- try program.program.setUniform(
- "cell_size",
- @Vector(2, f32){
- @floatFromInt(self.metrics.cell_width),
- @floatFromInt(self.metrics.cell_height),
- },
- );
- }
- }
-};
-
-const SetConfig = struct {
- fn apply(self: SetConfig, r: *const OpenGL) !void {
- _ = self;
- const gl_state = r.gl_state orelse return error.OpenGLUninitialized;
-
- const bind = try gl_state.cell_program.program.use();
- defer bind.unbind();
- try gl_state.cell_program.program.setUniform(
- "min_contrast",
- r.config.min_contrast,
- );
- }
-};
-
-/// The configuration for this renderer that is derived from the main
-/// configuration. This must be exported so that we don't need to
-/// pass around Config pointers which makes memory management a pain.
-pub const DerivedConfig = struct {
- arena: ArenaAllocator,
-
- font_thicken: bool,
- font_thicken_strength: u8,
- font_features: std.ArrayListUnmanaged([:0]const u8),
- font_styles: font.CodepointResolver.StyleStatus,
- cursor_color: ?terminal.color.RGB,
- cursor_invert: bool,
- cursor_text: ?terminal.color.RGB,
- cursor_opacity: f64,
- background: terminal.color.RGB,
- background_opacity: f64,
- foreground: terminal.color.RGB,
- selection_background: ?terminal.color.RGB,
- selection_foreground: ?terminal.color.RGB,
- invert_selection_fg_bg: bool,
- bold_is_bright: bool,
- min_contrast: f32,
- padding_color: configpkg.WindowPaddingColor,
- custom_shaders: configpkg.RepeatablePath,
- links: link.Set,
-
- pub fn init(
- alloc_gpa: Allocator,
- config: *const configpkg.Config,
- ) !DerivedConfig {
- var arena = ArenaAllocator.init(alloc_gpa);
- errdefer arena.deinit();
- const alloc = arena.allocator();
-
- // Copy our shaders
- const custom_shaders = try config.@"custom-shader".clone(alloc);
-
- // Copy our font features
- const font_features = try config.@"font-feature".clone(alloc);
-
- // Get our font styles
- var font_styles = font.CodepointResolver.StyleStatus.initFill(true);
- font_styles.set(.bold, config.@"font-style-bold" != .false);
- font_styles.set(.italic, config.@"font-style-italic" != .false);
- font_styles.set(.bold_italic, config.@"font-style-bold-italic" != .false);
-
- // Our link configs
- const links = try link.Set.fromConfig(
- alloc,
- config.link.links.items,
- );
-
- const cursor_invert = config.@"cursor-invert-fg-bg";
-
- return .{
- .background_opacity = @max(0, @min(1, config.@"background-opacity")),
- .font_thicken = config.@"font-thicken",
- .font_thicken_strength = config.@"font-thicken-strength",
- .font_features = font_features.list,
- .font_styles = font_styles,
-
- .cursor_color = if (!cursor_invert and config.@"cursor-color" != null)
- config.@"cursor-color".?.toTerminalRGB()
- else
- null,
-
- .cursor_invert = cursor_invert,
-
- .cursor_text = if (config.@"cursor-text") |txt|
- txt.toTerminalRGB()
- else
- null,
-
- .cursor_opacity = @max(0, @min(1, config.@"cursor-opacity")),
+const configpkg = @import("../config.zig");
+const rendererpkg = @import("../renderer.zig");
+const Renderer = rendererpkg.GenericRenderer(OpenGL);
- .background = config.background.toTerminalRGB(),
- .foreground = config.foreground.toTerminalRGB(),
- .invert_selection_fg_bg = config.@"selection-invert-fg-bg",
- .bold_is_bright = config.@"bold-is-bright",
- .min_contrast = @floatCast(config.@"minimum-contrast"),
- .padding_color = config.@"window-padding-color",
+pub const GraphicsAPI = OpenGL;
+pub const Target = @import("opengl/Target.zig");
+pub const Frame = @import("opengl/Frame.zig");
+pub const RenderPass = @import("opengl/RenderPass.zig");
+pub const Pipeline = @import("opengl/Pipeline.zig");
+const bufferpkg = @import("opengl/buffer.zig");
+pub const Buffer = bufferpkg.Buffer;
+pub const Texture = @import("opengl/Texture.zig");
+pub const shaders = @import("opengl/shaders.zig");
- .selection_background = if (config.@"selection-background") |bg|
- bg.toTerminalRGB()
- else
- null,
+pub const custom_shader_target: shadertoy.Target = .glsl;
+// The fragCoord for OpenGL shaders is +Y = up.
+pub const custom_shader_y_is_down = false;
- .selection_foreground = if (config.@"selection-foreground") |bg|
- bg.toTerminalRGB()
- else
- null,
+/// Because OpenGL's frame completion is always
+/// sync, we have no need for multi-buffering.
+pub const swap_chain_count = 1;
- .custom_shaders = custom_shaders,
- .links = links,
+const log = std.log.scoped(.opengl);
- .arena = arena,
- };
- }
+/// We require at least OpenGL 4.3
+pub const MIN_VERSION_MAJOR = 4;
+pub const MIN_VERSION_MINOR = 3;
- pub fn deinit(self: *DerivedConfig) void {
- const alloc = self.arena.allocator();
- self.links.deinit(alloc);
- self.arena.deinit();
- }
-};
-
-pub fn init(alloc: Allocator, options: renderer.Options) !OpenGL {
- // Create the initial font shaper
- var shaper = try font.Shaper.init(alloc, .{
- .features = options.config.font_features.items,
- });
- errdefer shaper.deinit();
+alloc: std.mem.Allocator,
- // For the remainder of the setup we lock our font grid data because
- // we're reading it.
- const grid = options.font_grid;
- grid.lock.lockShared();
- defer grid.lock.unlockShared();
+/// Alpha blending mode
+blending: configpkg.Config.AlphaBlending,
- var gl_state = try GLState.init(alloc, options.config, grid);
- errdefer gl_state.deinit();
+/// The most recently presented target, in case we need to present it again.
+last_target: ?Target = null,
- return OpenGL{
+/// NOTE: This is an error{}!OpenGL instead of just OpenGL for parity with
+/// Metal, since it needs to be fallible so does this, even though it
+/// can't actually fail.
+pub fn init(alloc: Allocator, opts: rendererpkg.Options) error{}!OpenGL {
+ return .{
.alloc = alloc,
- .config = options.config,
- .cells_bg = .{},
- .cells = .{},
- .grid_metrics = grid.metrics,
- .size = options.size,
- .gl_state = gl_state,
- .font_grid = grid,
- .font_shaper = shaper,
- .font_shaper_cache = font.ShaperCache.init(),
- .draw_background = options.config.background,
- .focused = true,
- .foreground_color = null,
- .default_foreground_color = options.config.foreground,
- .background_color = null,
- .default_background_color = options.config.background,
- .cursor_color = null,
- .default_cursor_color = options.config.cursor_color,
- .cursor_invert = options.config.cursor_invert,
- .surface_mailbox = options.surface_mailbox,
- .deferred_font_size = .{ .metrics = grid.metrics },
- .deferred_config = .{},
+ .blending = opts.config.blending,
};
}
pub fn deinit(self: *OpenGL) void {
- self.font_shaper.deinit();
- self.font_shaper_cache.deinit(self.alloc);
-
- {
- var it = self.images.iterator();
- while (it.next()) |kv| kv.value_ptr.image.deinit(self.alloc);
- self.images.deinit(self.alloc);
- }
- self.image_placements.deinit(self.alloc);
-
- if (self.gl_state) |*v| v.deinit(self.alloc);
-
- self.cells.deinit(self.alloc);
- self.cells_bg.deinit(self.alloc);
-
- self.config.deinit();
-
self.* = undefined;
}
/// Returns the hints that we want for this
pub fn glfwWindowHints(config: *const configpkg.Config) glfw.Window.Hints {
+ _ = config;
return .{
- .context_version_major = 3,
- .context_version_minor = 3,
+ .context_version_major = MIN_VERSION_MAJOR,
+ .context_version_minor = MIN_VERSION_MINOR,
.opengl_profile = .opengl_core_profile,
.opengl_forward_compat = true,
- .cocoa_graphics_switching = builtin.os.tag == .macos,
- .cocoa_retina_framebuffer = true,
- .transparent_framebuffer = config.@"background-opacity" < 1,
+ .transparent_framebuffer = true,
+ };
+}
+
+/// 32-bit windows cross-compilation breaks with `.c` for some reason, so...
+const gl_debug_proc_callconv =
+ @typeInfo(
+ @typeInfo(
+ @typeInfo(
+ gl.c.GLDEBUGPROC,
+ ).optional.child,
+ ).pointer.child,
+ ).@"fn".calling_convention;
+
+fn glDebugMessageCallback(
+ src: gl.c.GLenum,
+ typ: gl.c.GLenum,
+ id: gl.c.GLuint,
+ severity: gl.c.GLenum,
+ len: gl.c.GLsizei,
+ msg: [*c]const gl.c.GLchar,
+ user_param: ?*const anyopaque,
+) callconv(gl_debug_proc_callconv) void {
+ _ = user_param;
+
+ const src_str: []const u8 = switch (src) {
+ gl.c.GL_DEBUG_SOURCE_API => "OpenGL API",
+ gl.c.GL_DEBUG_SOURCE_WINDOW_SYSTEM => "Window System",
+ gl.c.GL_DEBUG_SOURCE_SHADER_COMPILER => "Shader Compiler",
+ gl.c.GL_DEBUG_SOURCE_THIRD_PARTY => "Third Party",
+ gl.c.GL_DEBUG_SOURCE_APPLICATION => "User",
+ gl.c.GL_DEBUG_SOURCE_OTHER => "Other",
+ else => "Unknown",
+ };
+
+ const typ_str: []const u8 = switch (typ) {
+ gl.c.GL_DEBUG_TYPE_ERROR => "Error",
+ gl.c.GL_DEBUG_TYPE_DEPRECATED_BEHAVIOR => "Deprecated Behavior",
+ gl.c.GL_DEBUG_TYPE_UNDEFINED_BEHAVIOR => "Undefined Behavior",
+ gl.c.GL_DEBUG_TYPE_PORTABILITY => "Portability Issue",
+ gl.c.GL_DEBUG_TYPE_PERFORMANCE => "Performance Issue",
+ gl.c.GL_DEBUG_TYPE_MARKER => "Marker",
+ gl.c.GL_DEBUG_TYPE_PUSH_GROUP => "Group Push",
+ gl.c.GL_DEBUG_TYPE_POP_GROUP => "Group Pop",
+ gl.c.GL_DEBUG_TYPE_OTHER => "Other",
+ else => "Unknown",
};
+
+ const msg_str = msg[0..@intCast(len)];
+
+ (switch (severity) {
+ gl.c.GL_DEBUG_SEVERITY_HIGH => log.err(
+ "[{d}] ({s}: {s}) {s}",
+ .{ id, src_str, typ_str, msg_str },
+ ),
+ gl.c.GL_DEBUG_SEVERITY_MEDIUM => log.warn(
+ "[{d}] ({s}: {s}) {s}",
+ .{ id, src_str, typ_str, msg_str },
+ ),
+ gl.c.GL_DEBUG_SEVERITY_LOW => log.info(
+ "[{d}] ({s}: {s}) {s}",
+ .{ id, src_str, typ_str, msg_str },
+ ),
+ gl.c.GL_DEBUG_SEVERITY_NOTIFICATION => log.debug(
+ "[{d}] ({s}: {s}) {s}",
+ .{ id, src_str, typ_str, msg_str },
+ ),
+ else => log.warn(
+ "UNKNOWN SEVERITY [{d}] ({s}: {s}) {s}",
+ .{ id, src_str, typ_str, msg_str },
+ ),
+ });
+}
+
+/// Prepares the provided GL context, loading it with glad.
+fn prepareContext(getProcAddress: anytype) !void {
+ const version = try gl.glad.load(getProcAddress);
+ const major = gl.glad.versionMajor(@intCast(version));
+ const minor = gl.glad.versionMinor(@intCast(version));
+ errdefer gl.glad.unload();
+ log.info("loaded OpenGL {}.{}", .{ major, minor });
+
+ // Enable debug output for the context.
+ try gl.enable(gl.c.GL_DEBUG_OUTPUT);
+
+ // Register our debug message callback with the OpenGL context.
+ gl.glad.context.DebugMessageCallback.?(glDebugMessageCallback, null);
+
+ // Enable SRGB framebuffer for linear blending support.
+ try gl.enable(gl.c.GL_FRAMEBUFFER_SRGB);
+
+ if (major < MIN_VERSION_MAJOR or
+ (major == MIN_VERSION_MAJOR and minor < MIN_VERSION_MINOR))
+ {
+ log.warn(
+ "OpenGL version is too old. Ghostty requires OpenGL {d}.{d}",
+ .{ MIN_VERSION_MAJOR, MIN_VERSION_MINOR },
+ );
+ return error.OpenGLOutdated;
+ }
}
/// This is called early right after surface creation.
@@ -455,20 +178,8 @@ pub fn surfaceInit(surface: *apprt.Surface) !void {
switch (apprt.runtime) {
else => @compileError("unsupported app runtime for OpenGL"),
- apprt.gtk => {
- // GTK uses global OpenGL context so we load from null.
- const version = try gl.glad.load(null);
- const major = gl.glad.versionMajor(@intCast(version));
- const minor = gl.glad.versionMinor(@intCast(version));
- errdefer gl.glad.unload();
- log.info("loaded OpenGL {}.{}", .{ major, minor });
-
- // We require at least OpenGL 3.3
- if (major < 3 or (major == 3 and minor < 3)) {
- log.warn("OpenGL version is too old. Ghostty requires OpenGL 3.3", .{});
- return error.OpenGLOutdated;
- }
- },
+ // GTK uses global OpenGL context so we load from null.
+ apprt.gtk => try prepareContext(null),
apprt.glfw => try self.threadEnter(surface),
@@ -489,69 +200,19 @@ pub fn surfaceInit(surface: *apprt.Surface) !void {
// }
}
-/// This is called just prior to spinning up the renderer thread for
-/// final main thread setup requirements.
+/// This is called just prior to spinning up the renderer
+/// thread for final main thread setup requirements.
pub fn finalizeSurfaceInit(self: *const OpenGL, surface: *apprt.Surface) !void {
_ = self;
_ = surface;
- // For GLFW, we grabbed the OpenGL context in surfaceInit and we
- // need to release it before we start the renderer thread.
+ // For GLFW, we grabbed the OpenGL context in surfaceInit and
+ // we need to release it before we start the renderer thread.
if (apprt.runtime == apprt.glfw) {
glfw.makeContextCurrent(null);
}
}
-/// Called when the OpenGL context is made invalid, so we need to free
-/// all previous resources and stop rendering.
-pub fn displayUnrealized(self: *OpenGL) void {
- if (single_threaded_draw) self.draw_mutex.lock();
- defer if (single_threaded_draw) self.draw_mutex.unlock();
-
- if (self.gl_state) |*v| {
- v.deinit(self.alloc);
- self.gl_state = null;
- }
-}
-
-/// Called when the OpenGL is ready to be initialized.
-pub fn displayRealize(self: *OpenGL) !void {
- if (single_threaded_draw) self.draw_mutex.lock();
- defer if (single_threaded_draw) self.draw_mutex.unlock();
-
- // Make our new state
- var gl_state = gl_state: {
- self.font_grid.lock.lockShared();
- defer self.font_grid.lock.unlockShared();
- break :gl_state try GLState.init(
- self.alloc,
- self.config,
- self.font_grid,
- );
- };
- errdefer gl_state.deinit();
-
- // Unrealize if we have to
- if (self.gl_state) |*v| v.deinit(self.alloc);
-
- // Set our new state
- self.gl_state = gl_state;
-
- // Make sure we invalidate all the fields so that we
- // reflush everything
- self.gl_cells_size = 0;
- self.gl_cells_written = 0;
- self.texture_grayscale_modified = 0;
- self.texture_color_modified = 0;
- self.texture_grayscale_resized = 0;
- self.texture_color_resized = 0;
-
- // We need to reset our uniforms
- self.deferred_screen_size = .{ .size = self.size };
- self.deferred_font_size = .{ .metrics = self.grid_metrics };
- self.deferred_config = .{};
-}
-
/// Callback called by renderer.Thread when it begins.
pub fn threadEnter(self: *const OpenGL, surface: *apprt.Surface) !void {
_ = self;
@@ -568,22 +229,17 @@ pub fn threadEnter(self: *const OpenGL, surface: *apprt.Surface) !void {
apprt.glfw => {
// We need to make the OpenGL context current. OpenGL requires
- // that a single thread own the a single OpenGL context (if any). This
- // ensures that the context switches over to our thread. Important:
- // the prior thread MUST have detached the context prior to calling
- // this entrypoint.
+ // that a single thread own the a single OpenGL context (if any).
+ // This ensures that the context switches over to our thread.
+ // Important: the prior thread MUST have detached the context
+ // prior to calling this entrypoint.
glfw.makeContextCurrent(surface.window);
errdefer glfw.makeContextCurrent(null);
glfw.swapInterval(1);
// Load OpenGL bindings. This API is context-aware so this sets
// a threadlocal context for these pointers.
- const version = try gl.glad.load(&glfw.getProcAddress);
- errdefer gl.glad.unload();
- log.info("loaded OpenGL {}.{}", .{
- gl.glad.versionMajor(@intCast(version)),
- gl.glad.versionMinor(@intCast(version)),
- });
+ try prepareContext(&glfw.getProcAddress);
},
apprt.embedded => {
@@ -617,2068 +273,199 @@ pub fn threadExit(self: *const OpenGL) void {
}
}
-/// True if our renderer has animations so that a higher frequency
-/// timer is used.
-pub fn hasAnimations(self: *const OpenGL) bool {
- const state = self.gl_state orelse return false;
- return state.custom != null;
-}
-
-/// See Metal
-pub fn hasVsync(self: *const OpenGL) bool {
+pub fn displayRealized(self: *const OpenGL) void {
_ = self;
- // OpenGL currently never has vsync
- return false;
-}
-
-/// See Metal.
-pub fn markDirty(self: *OpenGL) void {
- // Do nothing, we don't have dirty tracking yet.
- _ = self;
-}
+ switch (apprt.runtime) {
+ apprt.gtk => prepareContext(null) catch |err| {
+ log.warn(
+ "Error preparing GL context in displayRealized, err={}",
+ .{err},
+ );
+ },
-/// Callback when the focus changes for the terminal this is rendering.
-///
-/// Must be called on the render thread.
-pub fn setFocus(self: *OpenGL, focus: bool) !void {
- self.focused = focus;
+ else => @compileError("only GTK should be calling displayRealized"),
+ }
}
-/// Callback when the window is visible or occluded.
+/// Actions taken before doing anything in `drawFrame`.
///
-/// Must be called on the render thread.
-pub fn setVisible(self: *OpenGL, visible: bool) void {
+/// Right now there's nothing we need to do for OpenGL.
+pub fn drawFrameStart(self: *OpenGL) void {
_ = self;
- _ = visible;
}
-/// Set the new font grid.
+/// Actions taken after `drawFrame` is done.
///
-/// Must be called on the render thread.
-pub fn setFontGrid(self: *OpenGL, grid: *font.SharedGrid) void {
- if (single_threaded_draw) self.draw_mutex.lock();
- defer if (single_threaded_draw) self.draw_mutex.unlock();
-
- // Reset our font grid
- self.font_grid = grid;
- self.grid_metrics = grid.metrics;
- self.texture_grayscale_modified = 0;
- self.texture_grayscale_resized = 0;
- self.texture_color_modified = 0;
- self.texture_color_resized = 0;
-
- // Reset our shaper cache. If our font changed (not just the size) then
- // the data in the shaper cache may be invalid and cannot be used, so we
- // always clear the cache just in case.
- const font_shaper_cache = font.ShaperCache.init();
- self.font_shaper_cache.deinit(self.alloc);
- self.font_shaper_cache = font_shaper_cache;
-
- // Update our screen size because the font grid can affect grid
- // metrics which update uniforms.
- self.deferred_screen_size = .{ .size = self.size };
-
- // Defer our GPU updates
- self.deferred_font_size = .{ .metrics = grid.metrics };
-}
-
-/// The primary render callback that is completely thread-safe.
-pub fn updateFrame(
- self: *OpenGL,
- surface: *apprt.Surface,
- state: *renderer.State,
- cursor_blink_visible: bool,
-) !void {
- _ = surface;
-
- // Data we extract out of the critical area.
- const Critical = struct {
- full_rebuild: bool,
- gl_bg: terminal.color.RGB,
- screen: terminal.Screen,
- screen_type: terminal.ScreenType,
- mouse: renderer.State.Mouse,
- preedit: ?renderer.State.Preedit,
- cursor_style: ?renderer.CursorStyle,
- color_palette: terminal.color.Palette,
- };
-
- // Update all our data as tightly as possible within the mutex.
- var critical: Critical = critical: {
- state.mutex.lock();
- defer state.mutex.unlock();
-
- // If we're in a synchronized output state, we pause all rendering.
- if (state.terminal.modes.get(.synchronized_output)) {
- log.debug("synchronized output started, skipping render", .{});
- return;
- }
-
- // Swap bg/fg if the terminal is reversed
- const bg = self.background_color orelse self.default_background_color;
- const fg = self.foreground_color orelse self.default_foreground_color;
- defer {
- if (self.background_color) |*c| {
- c.* = bg;
- } else {
- self.default_background_color = bg;
- }
-
- if (self.foreground_color) |*c| {
- c.* = fg;
- } else {
- self.default_foreground_color = fg;
- }
- }
-
- if (state.terminal.modes.get(.reverse_colors)) {
- if (self.background_color) |*c| {
- c.* = fg;
- } else {
- self.default_background_color = fg;
- }
-
- if (self.foreground_color) |*c| {
- c.* = bg;
- } else {
- self.default_foreground_color = bg;
- }
- }
-
- // Get the viewport pin so that we can compare it to the current.
- const viewport_pin = state.terminal.screen.pages.pin(.{ .viewport = .{} }).?;
-
- // We used to share terminal state, but we've since learned through
- // analysis that it is faster to copy the terminal state than to
- // hold the lock while rebuilding GPU cells.
- var screen_copy = try state.terminal.screen.clone(
- self.alloc,
- .{ .viewport = .{} },
- null,
- );
- errdefer screen_copy.deinit();
-
- // Whether to draw our cursor or not.
- const cursor_style = if (state.terminal.flags.password_input)
- .lock
- else
- renderer.cursorStyle(
- state,
- self.focused,
- cursor_blink_visible,
- );
-
- // Get our preedit state
- const preedit: ?renderer.State.Preedit = preedit: {
- if (cursor_style == null) break :preedit null;
- const p = state.preedit orelse break :preedit null;
- break :preedit try p.clone(self.alloc);
- };
- errdefer if (preedit) |p| p.deinit(self.alloc);
-
- // If we have Kitty graphics data, we enter a SLOW SLOW SLOW path.
- // We only do this if the Kitty image state is dirty meaning only if
- // it changes.
- //
- // If we have any virtual references, we must also rebuild our
- // kitty state on every frame because any cell change can move
- // an image.
- if (state.terminal.screen.kitty_images.dirty or
- self.image_virtual)
- {
- // prepKittyGraphics touches self.images which is also used
- // in drawFrame so if we're drawing on a separate thread we need
- // to lock this.
- if (single_threaded_draw) self.draw_mutex.lock();
- defer if (single_threaded_draw) self.draw_mutex.unlock();
- try self.prepKittyGraphics(state.terminal);
- }
-
- // If we have any terminal dirty flags set then we need to rebuild
- // the entire screen. This can be optimized in the future.
- const full_rebuild: bool = rebuild: {
- {
- const Int = @typeInfo(terminal.Terminal.Dirty).@"struct".backing_integer.?;
- const v: Int = @bitCast(state.terminal.flags.dirty);
- if (v > 0) break :rebuild true;
- }
- {
- const Int = @typeInfo(terminal.Screen.Dirty).@"struct".backing_integer.?;
- const v: Int = @bitCast(state.terminal.screen.dirty);
- if (v > 0) break :rebuild true;
- }
-
- // If our viewport changed then we need to rebuild the entire
- // screen because it means we scrolled. If we have no previous
- // viewport then we must rebuild.
- const prev_viewport = self.cells_viewport orelse break :rebuild true;
- if (!prev_viewport.eql(viewport_pin)) break :rebuild true;
-
- break :rebuild false;
- };
-
- // Reset the dirty flags in the terminal and screen. We assume
- // that our rebuild will be successful since so we optimize for
- // success and reset while we hold the lock. This is much easier
- // than coordinating row by row or as changes are persisted.
- state.terminal.flags.dirty = .{};
- state.terminal.screen.dirty = .{};
- {
- var it = state.terminal.screen.pages.pageIterator(
- .right_down,
- .{ .screen = .{} },
- null,
- );
- while (it.next()) |chunk| {
- var dirty_set = chunk.node.data.dirtyBitSet();
- dirty_set.unsetAll();
- }
- }
-
- // Update our viewport pin for dirty tracking
- self.cells_viewport = viewport_pin;
-
- break :critical .{
- .full_rebuild = full_rebuild,
- .gl_bg = self.background_color orelse self.default_background_color,
- .screen = screen_copy,
- .screen_type = state.terminal.active_screen,
- .mouse = state.mouse,
- .preedit = preedit,
- .cursor_style = cursor_style,
- .color_palette = state.terminal.color_palette.colors,
- };
- };
- defer {
- critical.screen.deinit();
- if (critical.preedit) |p| p.deinit(self.alloc);
- }
-
- // Grab our draw mutex if we have it and update our data
- {
- if (single_threaded_draw) self.draw_mutex.lock();
- defer if (single_threaded_draw) self.draw_mutex.unlock();
-
- // Set our draw data
- self.draw_background = critical.gl_bg;
-
- // Build our GPU cells
- try self.rebuildCells(
- critical.full_rebuild,
- &critical.screen,
- critical.screen_type,
- critical.mouse,
- critical.preedit,
- critical.cursor_style,
- &critical.color_palette,
- );
-
- // Notify our shaper we're done for the frame. For some shapers like
- // CoreText this triggers off-thread cleanup logic.
- self.font_shaper.endFrame();
- }
+/// Right now there's nothing we need to do for OpenGL.
+pub fn drawFrameEnd(self: *OpenGL) void {
+ _ = self;
}
-/// 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.
-fn prepKittyGraphics(
- self: *OpenGL,
- t: *terminal.Terminal,
-) !void {
- const storage = &t.screen.kitty_images;
- defer storage.dirty = false;
-
- // We always clear our previous placements no matter what because
- // we rebuild them from scratch.
- self.image_placements.clearRetainingCapacity();
- self.image_virtual = false;
-
- // Go through our known images and if there are any that are no longer
- // in use then mark them to be freed.
- //
- // This never conflicts with the below because a placement can't
- // reference an image that doesn't exist.
- {
- var it = self.images.iterator();
- while (it.next()) |kv| {
- if (storage.imageById(kv.key_ptr.*) == null) {
- kv.value_ptr.image.markForUnload();
- }
- }
- }
-
- // The top-left and bottom-right corners of our viewport in screen
- // points. This lets us determine offsets and containment of placements.
- const top = t.screen.pages.getTopLeft(.viewport);
- const bot = t.screen.pages.getBottomRight(.viewport).?;
- 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.
- var it = storage.placements.iterator();
- while (it.next()) |kv| {
- // Find the image in storage
- const p = kv.value_ptr;
-
- // Special logic based on location
- switch (p.location) {
- .pin => {},
- .virtual => {
- // We need to mark virtual placements on our renderer so that
- // we know to rebuild in more scenarios since cell changes can
- // now trigger placement changes.
- self.image_virtual = true;
-
- // We also continue out because virtual placements are
- // only triggered by the unicode placeholder, not by the
- // placement itself.
- continue;
- },
- }
-
- const image = storage.imageById(kv.key_ptr.image_id) orelse {
- log.warn(
- "missing image for placement, ignoring image_id={}",
- .{kv.key_ptr.image_id},
- );
- continue;
- };
-
- try self.prepKittyPlacement(t, top_y, bot_y, &image, p);
- }
-
- // If we have virtual placements then we need to scan for placeholders.
- if (self.image_virtual) {
- var v_it = terminal.kitty.graphics.unicode.placementIterator(top, bot);
- while (v_it.next()) |virtual_p| try self.prepKittyVirtualPlacement(
- t,
- &virtual_p,
- );
- }
-
- // Sort the placements by their Z value.
- std.mem.sortUnstable(
- gl_image.Placement,
- self.image_placements.items,
- {},
- struct {
- fn lessThan(
- ctx: void,
- lhs: gl_image.Placement,
- rhs: gl_image.Placement,
- ) bool {
- _ = ctx;
- return lhs.z < rhs.z or (lhs.z == rhs.z and lhs.image_id < rhs.image_id);
- }
- }.lessThan,
+pub fn initShaders(
+ self: *const OpenGL,
+ alloc: Allocator,
+ custom_shaders: []const [:0]const u8,
+) !shaders.Shaders {
+ _ = alloc;
+ return try shaders.Shaders.init(
+ self.alloc,
+ custom_shaders,
);
-
- // 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;
- for (self.image_placements.items, 0..) |p, i| {
- if (bg_end == null and p.z >= bg_limit) {
- bg_end = @intCast(i);
- }
- if (text_end == null and p.z >= 0) {
- text_end = @intCast(i);
- }
- }
-
- self.image_bg_end = bg_end orelse 0;
- self.image_text_end = text_end orelse self.image_bg_end;
}
-fn prepKittyVirtualPlacement(
- self: *OpenGL,
- t: *terminal.Terminal,
- p: *const terminal.kitty.graphics.unicode.Placement,
-) !void {
- const storage = &t.screen.kitty_images;
- const image = storage.imageById(p.image_id) orelse {
- log.warn(
- "missing image for virtual placement, ignoring image_id={}",
- .{p.image_id},
- );
- return;
- };
-
- const rp = p.renderPlacement(
- storage,
- &image,
- self.grid_metrics.cell_width,
- self.grid_metrics.cell_height,
- ) catch |err| {
- log.warn("error rendering virtual placement err={}", .{err});
- return;
- };
-
- // If our placement is zero sized then we don't do anything.
- if (rp.dest_width == 0 or rp.dest_height == 0) return;
-
- const viewport: terminal.point.Point = t.screen.pages.pointFromPin(
- .viewport,
- rp.top_left,
- ) orelse {
- // This is unreachable with virtual placements because we should
- // only ever be looking at virtual placements that are in our
- // viewport in the renderer and virtual placements only ever take
- // up one row.
- unreachable;
- };
-
- // Send our image to the GPU and store the placement for rendering.
- try self.prepKittyImage(&image);
- try self.image_placements.append(self.alloc, .{
- .image_id = image.id,
- .x = @intCast(rp.top_left.x),
- .y = @intCast(viewport.viewport.y),
- .z = -1,
- .width = rp.dest_width,
- .height = rp.dest_height,
- .cell_offset_x = rp.offset_x,
- .cell_offset_y = rp.offset_y,
- .source_x = rp.source_x,
- .source_y = rp.source_y,
- .source_width = rp.source_width,
- .source_height = rp.source_height,
- });
-}
-
-fn prepKittyPlacement(
- self: *OpenGL,
- t: *terminal.Terminal,
- top_y: u32,
- bot_y: u32,
- image: *const terminal.kitty.graphics.Image,
- p: *const terminal.kitty.graphics.ImageStorage.Placement,
-) !void {
- // Get the rect for the placement. If this placement doesn't have
- // a rect then its virtual or something so skip it.
- const rect = p.rect(image.*, t) orelse return;
-
- // This is expensive but necessary.
- const img_top_y = t.screen.pages.pointFromPin(.screen, rect.top_left).?.screen.y;
- const img_bot_y = t.screen.pages.pointFromPin(.screen, rect.bottom_right).?.screen.y;
-
- // If the selection isn't within our viewport then skip it.
- 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.
- try self.prepKittyImage(image);
-
- // Calculate the dimensions of our image, taking in to
- // account the rows / columns specified by the placement.
- const dest_size = p.calculatedSize(image.*, t);
-
- // Calculate the source rectangle
- const source_x = @min(image.width, p.source_x);
- const source_y = @min(image.height, p.source_y);
- const source_width = if (p.source_width > 0)
- @min(image.width - source_x, p.source_width)
- else
- image.width;
- const source_height = if (p.source_height > 0)
- @min(image.height - source_y, p.source_height)
- else
- image.height;
-
- // Get the viewport-relative Y position of the placement.
- const y_pos: i32 = @as(i32, @intCast(img_top_y)) - @as(i32, @intCast(top_y));
-
- // Accumulate the placement
- if (dest_size.width > 0 and dest_size.height > 0) {
- try self.image_placements.append(self.alloc, .{
- .image_id = image.id,
- .x = @intCast(rect.top_left.x),
- .y = y_pos,
- .z = p.z,
- .width = dest_size.width,
- .height = dest_size.height,
- .cell_offset_x = p.x_offset,
- .cell_offset_y = p.y_offset,
- .source_x = source_x,
- .source_y = source_y,
- .source_width = source_width,
- .source_height = source_height,
- });
- }
-}
-
-fn prepKittyImage(
- self: *OpenGL,
- image: *const terminal.kitty.graphics.Image,
-) !void {
- // 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.
- const gop = try self.images.getOrPut(self.alloc, image.id);
- if (gop.found_existing and
- gop.value_ptr.transmit_time.order(image.transmit_time) == .eq)
- {
- return;
- }
-
- // Copy the data into the pending state.
- const data = try self.alloc.dupe(u8, image.data);
- errdefer self.alloc.free(data);
-
- // Store it in the map
- const pending: Image.Pending = .{
- .width = image.width,
- .height = image.height,
- .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
+/// Get the current size of the runtime surface.
+pub fn surfaceSize(self: *const OpenGL) !struct { width: u32, height: u32 } {
+ _ = self;
+ var viewport: [4]gl.c.GLint = undefined;
+ gl.glad.context.GetIntegerv.?(gl.c.GL_VIEWPORT, &viewport);
+ return .{
+ .width = @intCast(viewport[2]),
+ .height = @intCast(viewport[3]),
};
-
- if (!gop.found_existing) {
- gop.value_ptr.* = .{
- .image = new_image,
- .transmit_time = undefined,
- };
- } else {
- try gop.value_ptr.image.markForReplace(
- self.alloc,
- new_image,
- );
- }
-
- gop.value_ptr.transmit_time = image.transmit_time;
-}
-
-/// rebuildCells rebuilds all the GPU cells from our CPU state. This is a
-/// slow operation but ensures that the GPU state exactly matches the CPU state.
-/// In steady-state operation, we use some GPU tricks to send down stale data
-/// that is ignored. This accumulates more memory; rebuildCells clears it.
-///
-/// Note this doesn't have to typically be manually called. Internally,
-/// the renderer will do this when it needs more memory space.
-pub fn rebuildCells(
- self: *OpenGL,
- rebuild: bool,
- screen: *terminal.Screen,
- screen_type: terminal.ScreenType,
- mouse: renderer.State.Mouse,
- preedit: ?renderer.State.Preedit,
- cursor_style_: ?renderer.CursorStyle,
- color_palette: *const terminal.color.Palette,
-) !void {
- _ = screen_type;
-
- // Bg cells at most will need space for the visible screen size
- self.cells_bg.clearRetainingCapacity();
- self.cells.clearRetainingCapacity();
-
- // Create an arena for all our temporary allocations while rebuilding
- var arena = ArenaAllocator.init(self.alloc);
- defer arena.deinit();
- const arena_alloc = arena.allocator();
-
- // We've written no data to the GPU, refresh it all
- self.gl_cells_written = 0;
-
- // Create our match set for the links.
- var link_match_set: link.MatchSet = if (mouse.point) |mouse_pt| try self.config.links.matchSet(
- arena_alloc,
- screen,
- mouse_pt,
- mouse.mods,
- ) else .{};
-
- // Determine our x/y range for preedit. We don't want to render anything
- // here because we will render the preedit separately.
- const preedit_range: ?struct {
- y: terminal.size.CellCountInt,
- x: [2]terminal.size.CellCountInt,
- cp_offset: usize,
- } = if (preedit) |preedit_v| preedit: {
- const range = preedit_v.range(screen.cursor.x, screen.pages.cols - 1);
- break :preedit .{
- .y = screen.cursor.y,
- .x = .{ range.start, range.end },
- .cp_offset = range.cp_offset,
- };
- } else null;
-
- // These are all the foreground cells underneath the cursor.
- //
- // We keep track of these so that we can invert the colors and move them
- // in front of the block cursor so that the character remains visible.
- //
- // We init with a capacity of 4 to account for decorations such
- // as underline and strikethrough, as well as combining chars.
- var cursor_cells = try std.ArrayListUnmanaged(CellProgram.Cell).initCapacity(arena_alloc, 4);
- defer cursor_cells.deinit(arena_alloc);
-
- if (rebuild) {
- switch (self.config.padding_color) {
- .background => {},
-
- .extend, .@"extend-always" => {
- self.padding_extend_top = true;
- self.padding_extend_bottom = true;
- },
- }
- }
-
- const grid_size = self.size.grid();
-
- // We rebuild the cells row-by-row because we do font shaping by row.
- var row_it = screen.pages.rowIterator(.left_up, .{ .viewport = .{} }, null);
- // If our cell contents buffer is shorter than the screen viewport,
- // we render the rows that fit, starting from the bottom. If instead
- // the viewport is shorter than the cell contents buffer, we align
- // the top of the viewport with the top of the contents buffer.
- var y: terminal.size.CellCountInt = @min(
- screen.pages.rows,
- grid_size.rows,
- );
- while (row_it.next()) |row| {
- // The viewport may have more rows than our cell contents,
- // so we need to break from the loop early if we hit y = 0.
- if (y == 0) break;
-
- y -= 1;
-
- // True if we want to do font shaping around the cursor. We want to
- // do font shaping as long as the cursor is enabled.
- const shape_cursor = screen.viewportIsBottom() and
- y == screen.cursor.y;
-
- // If this is the row with our cursor, then we may have to modify
- // the cell with the cursor.
- const start_i: usize = self.cells.items.len;
- defer if (shape_cursor and cursor_style_ == .block) {
- const x = screen.cursor.x;
- const wide = row.cells(.all)[x].wide;
- const min_x = switch (wide) {
- .narrow, .spacer_head, .wide => x,
- .spacer_tail => x -| 1,
- };
- const max_x = switch (wide) {
- .narrow, .spacer_head, .spacer_tail => x,
- .wide => x +| 1,
- };
- for (self.cells.items[start_i..]) |cell| {
- if (cell.grid_col < min_x or cell.grid_col > max_x) continue;
- if (cell.mode.isFg()) {
- cursor_cells.append(arena_alloc, cell) catch {
- // We silently ignore if this fails because
- // worst case scenario some combining glyphs
- // aren't visible under the cursor '\_('-')_/'
- };
- }
- }
- };
-
- // We need to get this row's selection if there is one for proper
- // run splitting.
- const row_selection = sel: {
- const sel = screen.selection orelse break :sel null;
- const pin = screen.pages.pin(.{ .viewport = .{ .y = y } }) orelse
- break :sel null;
- break :sel sel.containedRow(screen, pin) orelse null;
- };
-
- // On primary screen, we still apply vertical padding extension
- // under certain conditions we feel are safe. This helps make some
- // scenarios look better while avoiding scenarios we know do NOT look
- // good.
- switch (self.config.padding_color) {
- // These already have the correct values set above.
- .background, .@"extend-always" => {},
-
- // Apply heuristics for padding extension.
- .extend => if (y == 0) {
- self.padding_extend_top = !row.neverExtendBg(
- color_palette,
- self.background_color orelse self.default_background_color,
- );
- } else if (y == self.size.grid().rows - 1) {
- self.padding_extend_bottom = !row.neverExtendBg(
- color_palette,
- self.background_color orelse self.default_background_color,
- );
- },
- }
-
- // 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 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;
-
- const row_cells_all = row.cells(.all);
-
- // If our viewport is wider than our cell contents buffer,
- // we still only process cells up to the width of the buffer.
- const row_cells = row_cells_all[0..@min(row_cells_all.len, grid_size.columns)];
-
- for (row_cells, 0..) |*cell, x| {
- // If this cell falls within our preedit range then we
- // skip this because preedits are setup separately.
- if (preedit_range) |range| preedit: {
- // We're not on the preedit line, no actions necessary.
- if (range.y != y) break :preedit;
- // We're before the preedit range, no actions necessary.
- if (x < range.x[0]) break :preedit;
- // We're in the preedit range, skip this cell.
- if (x <= range.x[1]) continue;
- // After exiting the preedit range we need to catch
- // the run position up because of the missed cells.
- // In all other cases, no action is necessary.
- if (x != range.x[1] + 1) break :preedit;
-
- // Step the run iterator until we find a run that ends
- // after the current cell, which will be the soonest run
- // that might contain glyphs for our cell.
- while (shaper_run) |run| {
- if (run.offset + run.cells > x) break;
- shaper_run = try run_iter.next(self.alloc);
- shaper_cells = null;
- shaper_cells_i = 0;
- }
-
- const run = shaper_run orelse break :preedit;
-
- // If we haven't shaped this run, do so now.
- shaper_cells = shaper_cells orelse
- // Try to read the cells from the shaping cache if we can.
- self.font_shaper_cache.get(run) orelse
- cache: {
- // Otherwise we have to shape them.
- const cells = try self.font_shaper.shape(run);
-
- // Try to cache them. If caching fails for any reason we
- // continue because it is just a performance optimization,
- // not a correctness issue.
- self.font_shaper_cache.put(
- self.alloc,
- run,
- cells,
- ) catch |err| {
- log.warn(
- "error caching font shaping results err={}",
- .{err},
- );
- };
-
- // The cells we get from direct shaping are always owned
- // by the shaper and valid until the next shaping call so
- // we can safely use them.
- break :cache cells;
- };
-
- // Advance our index until we reach or pass
- // our current x position in the shaper cells.
- while (shaper_cells.?[shaper_cells_i].x < x) {
- shaper_cells_i += 1;
- }
- }
-
- const wide = cell.wide;
-
- const style = row.style(cell);
-
- const cell_pin: terminal.Pin = cell: {
- var copy = row;
- copy.x = @intCast(x);
- break :cell copy;
- };
-
- // True if this cell is selected
- const selected: bool = if (screen.selection) |sel|
- sel.contains(screen, .{
- .node = row.node,
- .y = row.y,
- .x = @intCast(
- // Spacer tails should show the selection
- // state of the wide cell they belong to.
- if (wide == .spacer_tail)
- x -| 1
- else
- x,
- ),
- })
- else
- false;
-
- const bg_style = style.bg(cell, color_palette);
- const fg_style = style.fg(color_palette, self.config.bold_is_bright) orelse self.foreground_color orelse self.default_foreground_color;
-
- // The final background color for the cell.
- const bg = bg: {
- if (selected) {
- break :bg if (self.config.invert_selection_fg_bg)
- if (style.flags.inverse)
- // Cell is selected with invert selection fg/bg
- // enabled, and the cell has the inverse style
- // flag, so they cancel out and we get the normal
- // bg color.
- bg_style
- else
- // If it doesn't have the inverse style
- // flag then we use the fg color instead.
- fg_style
- else
- // If we don't have invert selection fg/bg set then we
- // just use the selection background if set, otherwise
- // the default fg color.
- break :bg self.config.selection_background orelse self.foreground_color orelse self.default_foreground_color;
- }
-
- // Not selected
- break :bg if (style.flags.inverse != isCovering(cell.codepoint()))
- // Two cases cause us to invert (use the fg color as the bg)
- // - The "inverse" style flag.
- // - A "covering" glyph; we use fg for bg in that case to
- // help make sure that padding extension works correctly.
- // If one of these is true (but not the other)
- // then we use the fg style color for the bg.
- fg_style
- else
- // Otherwise they cancel out.
- bg_style;
- };
-
- const fg = fg: {
- if (selected and !self.config.invert_selection_fg_bg) {
- // If we don't have invert selection fg/bg set
- // then we just use the selection foreground if
- // set, otherwise the default bg color.
- break :fg self.config.selection_foreground orelse self.background_color orelse self.default_background_color;
- }
-
- // Whether we need to use the bg color as our fg color:
- // - Cell is inverted and not selected
- // - Cell is selected and not inverted
- // Note: if selected then invert sel fg / bg must be
- // false since we separately handle it if true above.
- break :fg if (style.flags.inverse != selected)
- bg_style orelse self.background_color orelse self.default_background_color
- else
- fg_style;
- };
-
- // Foreground alpha for this cell.
- const alpha: u8 = if (style.flags.faint) 175 else 255;
-
- // If the cell has a background color, set it.
- const bg_color: [4]u8 = if (bg) |rgb| bg: {
- // Determine our background alpha. If we have transparency configured
- // then this is dynamic depending on some situations. This is all
- // in an attempt to make transparency look the best for various
- // situations. See inline comments.
- const bg_alpha: u8 = bg_alpha: {
- const default: u8 = 255;
-
- if (self.config.background_opacity >= 1) break :bg_alpha default;
-
- // If we're selected, we do not apply background opacity
- if (selected) break :bg_alpha default;
-
- // If we're reversed, do not apply background opacity
- if (style.flags.inverse) break :bg_alpha default;
-
- // If we have a background and its not the default background
- // then we apply background opacity
- if (style.bg(cell, color_palette) != null and !rgb.eql(self.background_color orelse self.default_background_color)) {
- break :bg_alpha default;
- }
-
- // We apply background opacity.
- var bg_alpha: f64 = @floatFromInt(default);
- bg_alpha *= self.config.background_opacity;
- bg_alpha = @ceil(bg_alpha);
- break :bg_alpha @intFromFloat(bg_alpha);
- };
-
- try self.cells_bg.append(self.alloc, .{
- .mode = .bg,
- .grid_col = @intCast(x),
- .grid_row = @intCast(y),
- .grid_width = cell.gridWidth(),
- .glyph_x = 0,
- .glyph_y = 0,
- .glyph_width = 0,
- .glyph_height = 0,
- .glyph_offset_x = 0,
- .glyph_offset_y = 0,
- .r = rgb.r,
- .g = rgb.g,
- .b = rgb.b,
- .a = bg_alpha,
- .bg_r = 0,
- .bg_g = 0,
- .bg_b = 0,
- .bg_a = 0,
- });
-
- break :bg .{
- rgb.r, rgb.g, rgb.b, bg_alpha,
- };
- } else .{
- self.draw_background.r,
- self.draw_background.g,
- self.draw_background.b,
- @intFromFloat(@max(0, @min(255, @round(self.config.background_opacity * 255)))),
- };
-
- // If the invisible flag is set on this cell then we
- // don't need to render any foreground elements, so
- // we just skip all glyphs with this x coordinate.
- //
- // NOTE: This behavior matches xterm. Some other terminal
- // emulators, e.g. Alacritty, still render text decorations
- // and only make the text itself invisible. The decision
- // has been made here to match xterm's behavior for this.
- if (style.flags.invisible) {
- continue;
- }
-
- // Give links a single underline, unless they already have
- // an underline, in which case use a double underline to
- // distinguish them.
- const underline: terminal.Attribute.Underline = if (link_match_set.contains(screen, cell_pin))
- if (style.flags.underline == .single)
- .double
- else
- .single
- else
- style.flags.underline;
-
- // We draw underlines first so that they layer underneath text.
- // This improves readability when a colored underline is used
- // which intersects parts of the text (descenders).
- if (underline != .none) self.addUnderline(
- @intCast(x),
- @intCast(y),
- underline,
- style.underlineColor(color_palette) orelse fg,
- alpha,
- bg_color,
- ) catch |err| {
- log.warn(
- "error adding underline to cell, will be invalid x={} y={}, err={}",
- .{ x, y, err },
- );
- };
-
- if (style.flags.overline) self.addOverline(
- @intCast(x),
- @intCast(y),
- fg,
- alpha,
- bg_color,
- ) catch |err| {
- log.warn(
- "error adding overline to cell, will be invalid x={} y={}, err={}",
- .{ x, y, err },
- );
- };
-
- // If we're at or past the end of our shaper run then
- // we need to get the next run from the run iterator.
- if (shaper_cells != null and shaper_cells_i >= shaper_cells.?.len) {
- shaper_run = try run_iter.next(self.alloc);
- shaper_cells = null;
- shaper_cells_i = 0;
- }
-
- if (shaper_run) |run| glyphs: {
- // If we haven't shaped this run yet, do so.
- shaper_cells = shaper_cells orelse
- // Try to read the cells from the shaping cache if we can.
- self.font_shaper_cache.get(run) orelse
- cache: {
- // Otherwise we have to shape them.
- const cells = try self.font_shaper.shape(run);
-
- // Try to cache them. If caching fails for any reason we
- // continue because it is just a performance optimization,
- // not a correctness issue.
- self.font_shaper_cache.put(
- self.alloc,
- run,
- cells,
- ) catch |err| {
- log.warn(
- "error caching font shaping results err={}",
- .{err},
- );
- };
-
- // The cells we get from direct shaping are always owned
- // by the shaper and valid until the next shaping call so
- // we can safely use them.
- break :cache cells;
- };
-
- const cells = shaper_cells orelse break :glyphs;
-
- // If there are no shaper cells for this run, ignore it.
- // This can occur for runs of empty cells, and is fine.
- if (cells.len == 0) break :glyphs;
-
- // If we encounter a shaper cell to the left of the current
- // cell then we have some problems. This logic relies on x
- // position monotonically increasing.
- assert(cells[shaper_cells_i].x >= x);
-
- // NOTE: An assumption is made here that a single cell will never
- // be present in more than one shaper run. If that assumption is
- // violated, this logic breaks.
-
- while (shaper_cells_i < cells.len and cells[shaper_cells_i].x == x) : ({
- shaper_cells_i += 1;
- }) {
- self.addGlyph(
- @intCast(x),
- @intCast(y),
- cell_pin,
- cells[shaper_cells_i],
- shaper_run.?,
- fg,
- alpha,
- bg_color,
- ) catch |err| {
- log.warn(
- "error adding glyph to cell, will be invalid x={} y={}, err={}",
- .{ x, y, err },
- );
- };
- }
- }
-
- // Finally, draw a strikethrough if necessary.
- if (style.flags.strikethrough) self.addStrikethrough(
- @intCast(x),
- @intCast(y),
- fg,
- alpha,
- bg_color,
- ) catch |err| {
- log.warn(
- "error adding strikethrough to cell, will be invalid x={} y={}, err={}",
- .{ x, y, err },
- );
- };
- }
- }
-
- // Add the cursor at the end so that it overlays everything. If we have
- // a cursor cell then we invert the colors on that and add it in so
- // that we can always see it.
- if (cursor_style_) |cursor_style| cursor_style: {
- // If we have a preedit, we try to render the preedit text on top
- // of the cursor.
- if (preedit) |preedit_v| {
- const range = preedit_range.?;
- var x = range.x[0];
- for (preedit_v.codepoints[range.cp_offset..]) |cp| {
- self.addPreeditCell(cp, x, range.y) catch |err| {
- log.warn("error building preedit cell, will be invalid x={} y={}, err={}", .{
- x,
- range.y,
- err,
- });
- };
-
- x += if (cp.wide) 2 else 1;
- }
-
- // Preedit hides the cursor
- break :cursor_style;
- }
-
- const cursor_color = self.cursor_color orelse self.default_cursor_color orelse color: {
- if (self.cursor_invert) {
- // Use the foreground color from the cell under the cursor, if any.
- const sty = screen.cursor.page_pin.style(screen.cursor.page_cell);
- break :color if (sty.flags.inverse)
- // If the cell is reversed, use background color instead.
- (sty.bg(screen.cursor.page_cell, color_palette) orelse self.background_color orelse self.default_background_color)
- else
- (sty.fg(color_palette, self.config.bold_is_bright) orelse self.foreground_color orelse self.default_foreground_color);
- } else {
- break :color self.foreground_color orelse self.default_foreground_color;
- }
- };
-
- _ = try self.addCursor(screen, cursor_style, cursor_color);
- for (cursor_cells.items) |*cell| {
- if (cell.mode.isFg() and cell.mode != .fg_color) {
- const cell_color = if (self.cursor_invert) blk: {
- // Use the background color from the cell under the cursor, if any.
- const sty = screen.cursor.page_pin.style(screen.cursor.page_cell);
- break :blk if (sty.flags.inverse)
- // If the cell is reversed, use foreground color instead.
- (sty.fg(color_palette, self.config.bold_is_bright) orelse self.foreground_color orelse self.default_foreground_color)
- else
- (sty.bg(screen.cursor.page_cell, color_palette) orelse self.background_color orelse self.default_background_color);
- } else if (self.config.cursor_text) |txt|
- txt
- else
- self.background_color orelse self.default_background_color;
-
- cell.r = cell_color.r;
- cell.g = cell_color.g;
- cell.b = cell_color.b;
- cell.a = 255;
- }
- try self.cells.append(self.alloc, cell.*);
- }
- }
-
- // Free up memory, generally in case where surface has shrunk.
- // If more than half of the capacity is unused, remove all unused capacity.
- if (self.cells.items.len * 2 < self.cells.capacity) {
- self.cells.shrinkAndFree(self.alloc, self.cells.items.len);
- }
- if (self.cells_bg.items.len * 2 < self.cells_bg.capacity) {
- self.cells_bg.shrinkAndFree(self.alloc, self.cells_bg.items.len);
- }
-
- // Some debug mode safety checks
- if (std.debug.runtime_safety) {
- for (self.cells_bg.items) |cell| assert(cell.mode == .bg);
- for (self.cells.items) |cell| assert(cell.mode != .bg);
- }
}
-fn addPreeditCell(
- self: *OpenGL,
- cp: renderer.State.Preedit.Codepoint,
- x: usize,
- y: usize,
-) !void {
- // Preedit is rendered inverted
- const bg = self.foreground_color orelse self.default_foreground_color;
- const fg = self.background_color orelse self.default_background_color;
-
- // Render the glyph for our preedit text
- const render_ = self.font_grid.renderCodepoint(
- self.alloc,
- @intCast(cp.codepoint),
- .regular,
- .text,
- .{ .grid_metrics = self.grid_metrics },
- ) catch |err| {
- log.warn("error rendering preedit glyph err={}", .{err});
- return;
- };
- const render = render_ orelse {
- log.warn("failed to find font for preedit codepoint={X}", .{cp.codepoint});
- return;
- };
-
- // Add our opaque background cell
- try self.cells_bg.append(self.alloc, .{
- .mode = .bg,
- .grid_col = @intCast(x),
- .grid_row = @intCast(y),
- .grid_width = if (cp.wide) 2 else 1,
- .glyph_x = 0,
- .glyph_y = 0,
- .glyph_width = 0,
- .glyph_height = 0,
- .glyph_offset_x = 0,
- .glyph_offset_y = 0,
- .r = bg.r,
- .g = bg.g,
- .b = bg.b,
- .a = 255,
- .bg_r = 0,
- .bg_g = 0,
- .bg_b = 0,
- .bg_a = 0,
- });
-
- // Add our text
- try self.cells.append(self.alloc, .{
- .mode = .fg,
- .grid_col = @intCast(x),
- .grid_row = @intCast(y),
- .grid_width = if (cp.wide) 2 else 1,
- .glyph_x = render.glyph.atlas_x,
- .glyph_y = render.glyph.atlas_y,
- .glyph_width = render.glyph.width,
- .glyph_height = render.glyph.height,
- .glyph_offset_x = render.glyph.offset_x,
- .glyph_offset_y = render.glyph.offset_y,
- .r = fg.r,
- .g = fg.g,
- .b = fg.b,
- .a = 255,
- .bg_r = bg.r,
- .bg_g = bg.g,
- .bg_b = bg.b,
- .bg_a = 255,
+/// Initialize a new render target which can be presented by this API.
+pub fn initTarget(self: *const OpenGL, width: usize, height: usize) !Target {
+ return Target.init(.{
+ .internal_format = if (self.blending.isLinear()) .srgba else .rgba,
+ .width = width,
+ .height = height,
});
}
-fn addCursor(
- self: *OpenGL,
- screen: *terminal.Screen,
- cursor_style: renderer.CursorStyle,
- cursor_color: terminal.color.RGB,
-) !?*const CellProgram.Cell {
- // Add the cursor. We render the cursor over the wide character if
- // we're on the wide character tail.
- const wide, const x = cell: {
- // The cursor goes over the screen cursor position.
- const cell = screen.cursor.page_cell;
- if (cell.wide != .spacer_tail or screen.cursor.x == 0)
- break :cell .{ cell.wide == .wide, screen.cursor.x };
-
- // If we're part of a wide character, we move the cursor back to
- // the actual character.
- const prev_cell = screen.cursorCellLeft(1);
- break :cell .{ prev_cell.wide == .wide, screen.cursor.x - 1 };
- };
-
- const alpha: u8 = if (!self.focused) 255 else alpha: {
- const alpha = 255 * self.config.cursor_opacity;
- break :alpha @intFromFloat(@ceil(alpha));
- };
-
- const render = switch (cursor_style) {
- .block,
- .block_hollow,
- .bar,
- .underline,
- => render: {
- const sprite: font.Sprite = switch (cursor_style) {
- .block => .cursor_rect,
- .block_hollow => .cursor_hollow_rect,
- .bar => .cursor_bar,
- .underline => .underline,
- .lock => unreachable,
- };
-
- break :render self.font_grid.renderGlyph(
- self.alloc,
- font.sprite_index,
- @intFromEnum(sprite),
- .{
- .cell_width = if (wide) 2 else 1,
- .grid_metrics = self.grid_metrics,
- },
- ) catch |err| {
- log.warn("error rendering cursor glyph err={}", .{err});
- return null;
- };
- },
-
- .lock => self.font_grid.renderCodepoint(
- self.alloc,
- 0xF023, // lock symbol
- .regular,
- .text,
- .{
- .cell_width = if (wide) 2 else 1,
- .grid_metrics = self.grid_metrics,
- },
- ) catch |err| {
- log.warn("error rendering cursor glyph err={}", .{err});
- return null;
- } orelse {
- // This should never happen because we embed nerd
- // fonts so we just log and return instead of fallback.
- log.warn("failed to find lock symbol for cursor codepoint=0xF023", .{});
- return null;
- },
- };
-
- try self.cells.append(self.alloc, .{
- .mode = .fg,
- .grid_col = @intCast(x),
- .grid_row = @intCast(screen.cursor.y),
- .grid_width = if (wide) 2 else 1,
- .r = cursor_color.r,
- .g = cursor_color.g,
- .b = cursor_color.b,
- .a = alpha,
- .bg_r = 0,
- .bg_g = 0,
- .bg_b = 0,
- .bg_a = 0,
- .glyph_x = render.glyph.atlas_x,
- .glyph_y = render.glyph.atlas_y,
- .glyph_width = render.glyph.width,
- .glyph_height = render.glyph.height,
- .glyph_offset_x = render.glyph.offset_x,
- .glyph_offset_y = render.glyph.offset_y,
- });
+/// Present the provided target.
+pub fn present(self: *OpenGL, target: Target) !void {
+ // In order to present a target we blit it to the default framebuffer.
- return &self.cells.items[self.cells.items.len - 1];
-}
-
-/// Add an underline decoration to the specified cell
-fn addUnderline(
- self: *OpenGL,
- x: terminal.size.CellCountInt,
- y: terminal.size.CellCountInt,
- style: terminal.Attribute.Underline,
- color: terminal.color.RGB,
- alpha: u8,
- bg: [4]u8,
-) !void {
- const sprite: font.Sprite = switch (style) {
- .none => unreachable,
- .single => .underline,
- .double => .underline_double,
- .dotted => .underline_dotted,
- .dashed => .underline_dashed,
- .curly => .underline_curly,
+ // We disable GL_FRAMEBUFFER_SRGB while doing this blit, otherwise the
+ // values may be linearized as they're copied, but even though the draw
+ // framebuffer has a linear internal format, the values in it should be
+ // sRGB, not linear!
+ try gl.disable(gl.c.GL_FRAMEBUFFER_SRGB);
+ defer gl.enable(gl.c.GL_FRAMEBUFFER_SRGB) catch |err| {
+ log.err("Error re-enabling GL_FRAMEBUFFER_SRGB, err={}", .{err});
};
- const render = try self.font_grid.renderGlyph(
- self.alloc,
- font.sprite_index,
- @intFromEnum(sprite),
- .{
- .cell_width = 1,
- .grid_metrics = self.grid_metrics,
- },
- );
-
- try self.cells.append(self.alloc, .{
- .mode = .fg,
- .grid_col = @intCast(x),
- .grid_row = @intCast(y),
- .grid_width = 1,
- .glyph_x = render.glyph.atlas_x,
- .glyph_y = render.glyph.atlas_y,
- .glyph_width = render.glyph.width,
- .glyph_height = render.glyph.height,
- .glyph_offset_x = render.glyph.offset_x,
- .glyph_offset_y = render.glyph.offset_y,
- .r = color.r,
- .g = color.g,
- .b = color.b,
- .a = alpha,
- .bg_r = bg[0],
- .bg_g = bg[1],
- .bg_b = bg[2],
- .bg_a = bg[3],
- });
-}
-
-/// Add an overline decoration to the specified cell
-fn addOverline(
- self: *OpenGL,
- x: terminal.size.CellCountInt,
- y: terminal.size.CellCountInt,
- color: terminal.color.RGB,
- alpha: u8,
- bg: [4]u8,
-) !void {
- const render = try self.font_grid.renderGlyph(
- self.alloc,
- font.sprite_index,
- @intFromEnum(font.Sprite.overline),
- .{
- .cell_width = 1,
- .grid_metrics = self.grid_metrics,
- },
+ // Bind the target for reading.
+ const fbobind = try target.framebuffer.bind(.read);
+ defer fbobind.unbind();
+
+ // Blit
+ gl.glad.context.BlitFramebuffer.?(
+ 0,
+ 0,
+ @intCast(target.width),
+ @intCast(target.height),
+ 0,
+ 0,
+ @intCast(target.width),
+ @intCast(target.height),
+ gl.c.GL_COLOR_BUFFER_BIT,
+ gl.c.GL_NEAREST,
);
- try self.cells.append(self.alloc, .{
- .mode = .fg,
- .grid_col = @intCast(x),
- .grid_row = @intCast(y),
- .grid_width = 1,
- .glyph_x = render.glyph.atlas_x,
- .glyph_y = render.glyph.atlas_y,
- .glyph_width = render.glyph.width,
- .glyph_height = render.glyph.height,
- .glyph_offset_x = render.glyph.offset_x,
- .glyph_offset_y = render.glyph.offset_y,
- .r = color.r,
- .g = color.g,
- .b = color.b,
- .a = alpha,
- .bg_r = bg[0],
- .bg_g = bg[1],
- .bg_b = bg[2],
- .bg_a = bg[3],
- });
+ // Keep track of this target in case we need to repeat it.
+ self.last_target = target;
}
-/// Add a strikethrough decoration to the specified cell
-fn addStrikethrough(
- self: *OpenGL,
- x: terminal.size.CellCountInt,
- y: terminal.size.CellCountInt,
- color: terminal.color.RGB,
- alpha: u8,
- bg: [4]u8,
-) !void {
- const render = try self.font_grid.renderGlyph(
- self.alloc,
- font.sprite_index,
- @intFromEnum(font.Sprite.strikethrough),
- .{
- .cell_width = 1,
- .grid_metrics = self.grid_metrics,
- },
- );
-
- try self.cells.append(self.alloc, .{
- .mode = .fg,
- .grid_col = @intCast(x),
- .grid_row = @intCast(y),
- .grid_width = 1,
- .glyph_x = render.glyph.atlas_x,
- .glyph_y = render.glyph.atlas_y,
- .glyph_width = render.glyph.width,
- .glyph_height = render.glyph.height,
- .glyph_offset_x = render.glyph.offset_x,
- .glyph_offset_y = render.glyph.offset_y,
- .r = color.r,
- .g = color.g,
- .b = color.b,
- .a = alpha,
- .bg_r = bg[0],
- .bg_g = bg[1],
- .bg_b = bg[2],
- .bg_a = bg[3],
- });
+/// Present the last presented target again.
+pub fn presentLastTarget(self: *OpenGL) !void {
+ if (self.last_target) |target| try self.present(target);
}
-// Add a glyph to the specified cell.
-fn addGlyph(
- self: *OpenGL,
- x: terminal.size.CellCountInt,
- y: terminal.size.CellCountInt,
- cell_pin: terminal.Pin,
- shaper_cell: font.shape.Cell,
- shaper_run: font.shape.TextRun,
- color: terminal.color.RGB,
- alpha: u8,
- bg: [4]u8,
-) !void {
- const rac = cell_pin.rowAndCell();
- const cell = rac.cell;
-
- // Render
- const render = try self.font_grid.renderGlyph(
- self.alloc,
- shaper_run.font_index,
- shaper_cell.glyph_index,
- .{
- .cell_width = if (cell.wide == .wide) 2 else 1,
- .grid_metrics = self.grid_metrics,
- .thicken = self.config.font_thicken,
- .thicken_strength = self.config.font_thicken_strength,
- },
- );
-
- // If the glyph is 0 width or height, it will be invisible
- // when drawn, so don't bother adding it to the buffer.
- if (render.glyph.width == 0 or render.glyph.height == 0) {
- return;
- }
-
- // If we're rendering a color font, we use the color atlas
- const mode: CellProgram.CellMode = switch (try fgMode(
- render.presentation,
- cell_pin,
- )) {
- .normal => .fg,
- .color => .fg_color,
- .constrained => .fg_constrained,
- .powerline => .fg_powerline,
+/// Returns the options to use when constructing buffers.
+pub inline fn bufferOptions(self: OpenGL) bufferpkg.Options {
+ _ = self;
+ return .{
+ .target = .array,
+ .usage = .dynamic_draw,
};
-
- try self.cells.append(self.alloc, .{
- .mode = mode,
- .grid_col = @intCast(x),
- .grid_row = @intCast(y),
- .grid_width = cell.gridWidth(),
- .glyph_x = render.glyph.atlas_x,
- .glyph_y = render.glyph.atlas_y,
- .glyph_width = render.glyph.width,
- .glyph_height = render.glyph.height,
- .glyph_offset_x = render.glyph.offset_x + shaper_cell.x_offset,
- .glyph_offset_y = render.glyph.offset_y + shaper_cell.y_offset,
- .r = color.r,
- .g = color.g,
- .b = color.b,
- .a = alpha,
- .bg_r = bg[0],
- .bg_g = bg[1],
- .bg_b = bg[2],
- .bg_a = bg[3],
- });
-}
-
-/// Update the configuration.
-pub fn changeConfig(self: *OpenGL, config: *DerivedConfig) !void {
- // We always redo the font shaper in case font features changed. We
- // could check to see if there was an actual config change but this is
- // easier and rare enough to not cause performance issues.
- {
- var font_shaper = try font.Shaper.init(self.alloc, .{
- .features = config.font_features.items,
- });
- errdefer font_shaper.deinit();
- self.font_shaper.deinit();
- self.font_shaper = font_shaper;
- }
-
- // We also need to reset the shaper cache so shaper info
- // from the previous font isn't re-used for the new font.
- const font_shaper_cache = font.ShaperCache.init();
- self.font_shaper_cache.deinit(self.alloc);
- self.font_shaper_cache = font_shaper_cache;
-
- // Set our new colors
- self.default_background_color = config.background;
- self.default_foreground_color = config.foreground;
- self.default_cursor_color = if (!config.cursor_invert) config.cursor_color else null;
- self.cursor_invert = config.cursor_invert;
-
- // Update our uniforms
- self.deferred_config = .{};
-
- self.config.deinit();
- self.config = config.*;
-}
-
-/// Set the screen size for rendering. This will update the projection
-/// used for the shader so that the scaling of the grid is correct.
-pub fn setScreenSize(
- self: *OpenGL,
- size: renderer.Size,
-) !void {
- if (single_threaded_draw) self.draw_mutex.lock();
- defer if (single_threaded_draw) self.draw_mutex.unlock();
-
- // Store our screen size
- self.size = size;
-
- // Defer our OpenGL updates
- self.deferred_screen_size = .{ .size = size };
-
- log.debug("screen size size={}", .{size});
-}
-
-/// Updates the font texture atlas if it is dirty.
-fn flushAtlas(self: *OpenGL) !void {
- const gl_state = self.gl_state orelse return;
- try flushAtlasSingle(
- &self.font_grid.lock,
- gl_state.texture,
- &self.font_grid.atlas_grayscale,
- &self.texture_grayscale_modified,
- &self.texture_grayscale_resized,
- .red,
- .red,
- );
- try flushAtlasSingle(
- &self.font_grid.lock,
- gl_state.texture_color,
- &self.font_grid.atlas_color,
- &self.texture_color_modified,
- &self.texture_color_resized,
- .rgba,
- .bgra,
- );
}
-/// Flush a single atlas, grabbing all necessary locks, checking for
-/// changes, etc.
-fn flushAtlasSingle(
- lock: *std.Thread.RwLock,
- texture: gl.Texture,
- atlas: *font.Atlas,
- modified: *usize,
- resized: *usize,
- internal_format: gl.Texture.InternalFormat,
- format: gl.Texture.Format,
-) !void {
- // If the texture isn't modified we do nothing
- const new_modified = atlas.modified.load(.monotonic);
- if (new_modified <= modified.*) return;
-
- // If it is modified we need to grab a read-lock
- lock.lockShared();
- defer lock.unlockShared();
-
- var texbind = try texture.bind(.@"2D");
- defer texbind.unbind();
-
- const new_resized = atlas.resized.load(.monotonic);
- if (new_resized > resized.*) {
- try texbind.image2D(
- 0,
- internal_format,
- @intCast(atlas.size),
- @intCast(atlas.size),
- 0,
- format,
- .UnsignedByte,
- atlas.data.ptr,
- );
-
- // Only update the resized number after successful resize
- resized.* = new_resized;
- } else {
- try texbind.subImage2D(
- 0,
- 0,
- 0,
- @intCast(atlas.size),
- @intCast(atlas.size),
- format,
- .UnsignedByte,
- atlas.data.ptr,
- );
- }
+pub const instanceBufferOptions = bufferOptions;
+pub const uniformBufferOptions = bufferOptions;
+pub const fgBufferOptions = bufferOptions;
+pub const bgBufferOptions = bufferOptions;
+pub const imageBufferOptions = bufferOptions;
+pub const bgImageBufferOptions = bufferOptions;
- // Update our modified tracker after successful update
- modified.* = atlas.modified.load(.monotonic);
+/// Returns the options to use when constructing textures.
+pub inline fn textureOptions(self: OpenGL) Texture.Options {
+ _ = self;
+ return .{
+ .format = .rgba,
+ .internal_format = .srgba,
+ .target = .@"2D",
+ };
}
-/// Render renders the current cell state. This will not modify any of
-/// the cells.
-pub fn drawFrame(self: *OpenGL, surface: *apprt.Surface) !void {
- // If we're in single-threaded more we grab a lock since we use shared data.
- if (single_threaded_draw) self.draw_mutex.lock();
- defer if (single_threaded_draw) self.draw_mutex.unlock();
- const gl_state: *GLState = if (self.gl_state) |*v| v else return;
-
- // Go through our images and see if we need to setup any textures.
- {
- var image_it = self.images.iterator();
- while (image_it.next()) |kv| {
- switch (kv.value_ptr.image) {
- .ready => {},
-
- .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),
-
- .unload_pending,
- .unload_replace,
- .unload_ready,
- => {
- kv.value_ptr.image.deinit(self.alloc);
- self.images.removeByPtr(kv.key_ptr);
- },
- }
- }
- }
-
- // In the "OpenGL Programming Guide for Mac" it explains that: "When you
- // use an NSOpenGLView object with OpenGL calls that are issued from a
- // thread other than the main one, you must set up mutex locking."
- // This locks the context and avoids crashes that can happen due to
- // races with the underlying Metal layer that Apple is using to
- // implement OpenGL.
- const is_darwin = builtin.target.os.tag.isDarwin();
- const ogl = if (comptime is_darwin) @cImport({
- @cInclude("OpenGL/OpenGL.h");
- }) else {};
- const cgl_ctx = if (comptime is_darwin) ogl.CGLGetCurrentContext();
- if (comptime is_darwin) _ = ogl.CGLLockContext(cgl_ctx);
- defer _ = if (comptime is_darwin) ogl.CGLUnlockContext(cgl_ctx);
-
- // If our viewport size doesn't match the saved screen size then
- // we need to update it. We rely on this over setScreenSize because
- // we can pull it directly from the OpenGL context instead of relying
- // on the eventual message.
- {
- var viewport: [4]gl.c.GLint = undefined;
- gl.glad.context.GetIntegerv.?(gl.c.GL_VIEWPORT, &viewport);
- const screen: renderer.ScreenSize = .{
- .width = @intCast(viewport[2]),
- .height = @intCast(viewport[3]),
+/// 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,
};
- if (!screen.equals(self.size.screen)) {
- self.size.screen = screen;
- self.deferred_screen_size = .{ .size = self.size };
- }
- }
-
- // Draw our terminal cells
- try self.drawCellProgram(gl_state);
-
- // Draw our custom shaders
- if (gl_state.custom) |*custom_state| {
- try self.drawCustomPrograms(custom_state);
}
+};
- // Swap our window buffers
- switch (apprt.runtime) {
- apprt.glfw => surface.window.swapBuffers(),
- apprt.gtk => {},
- apprt.embedded => {},
- else => @compileError("unsupported runtime"),
- }
-}
-
-/// Draw the custom shaders.
-fn drawCustomPrograms(self: *OpenGL, custom_state: *custom.State) !void {
+/// Returns the options to use when constructing textures for images.
+pub inline fn imageTextureOptions(
+ self: OpenGL,
+ format: ImageTextureFormat,
+ srgb: bool,
+) Texture.Options {
_ = self;
- assert(custom_state.programs.len > 0);
-
- // Bind our state that is global to all custom shaders
- const custom_bind = try custom_state.bind();
- defer custom_bind.unbind();
-
- // Setup the new frame
- try custom_state.newFrame();
-
- // Go through each custom shader and draw it.
- for (custom_state.programs) |program| {
- const bind = try program.bind();
- defer bind.unbind();
- try bind.draw();
- try custom_state.copyFramebuffer();
- }
-}
-
-/// Runs the cell program (shaders) to draw the terminal grid.
-fn drawCellProgram(
- self: *OpenGL,
- gl_state: *const GLState,
-) !void {
- // Try to flush our atlas, this will only do something if there
- // are changes to the atlas.
- try self.flushAtlas();
-
- // If we have custom shaders, then we draw to the custom
- // shader framebuffer.
- const fbobind: ?gl.Framebuffer.Binding = fbobind: {
- const state = gl_state.custom orelse break :fbobind null;
- break :fbobind try state.fbo.bind(.framebuffer);
+ return .{
+ .format = format.toPixelFormat(),
+ .internal_format = if (srgb) .srgba else .rgba,
+ .target = .@"2D",
};
- defer if (fbobind) |v| v.unbind();
-
- // Clear the surface
- gl.clearColor(
- @floatCast(@as(f32, @floatFromInt(self.draw_background.r)) / 255 * self.config.background_opacity),
- @floatCast(@as(f32, @floatFromInt(self.draw_background.g)) / 255 * self.config.background_opacity),
- @floatCast(@as(f32, @floatFromInt(self.draw_background.b)) / 255 * self.config.background_opacity),
- @floatCast(self.config.background_opacity),
- );
- gl.clear(gl.c.GL_COLOR_BUFFER_BIT);
-
- // If we have deferred operations, run them.
- if (self.deferred_screen_size) |v| {
- try v.apply(self);
- self.deferred_screen_size = null;
- }
- if (self.deferred_font_size) |v| {
- try v.apply(self);
- self.deferred_font_size = null;
- }
- if (self.deferred_config) |v| {
- try v.apply(self);
- self.deferred_config = null;
- }
-
- // Apply our padding extension fields
- {
- const program = gl_state.cell_program;
- const bind = try program.program.use();
- defer bind.unbind();
- try program.program.setUniform(
- "padding_vertical_top",
- self.padding_extend_top,
- );
- try program.program.setUniform(
- "padding_vertical_bottom",
- self.padding_extend_bottom,
- );
- }
-
- // Draw background images first
- try self.drawImages(
- gl_state,
- self.image_placements.items[0..self.image_bg_end],
- );
-
- // Draw our background
- try self.drawCells(gl_state, self.cells_bg);
-
- // Then draw images under text
- try self.drawImages(
- gl_state,
- self.image_placements.items[self.image_bg_end..self.image_text_end],
- );
-
- // Drag foreground
- try self.drawCells(gl_state, self.cells);
-
- // Draw remaining images
- try self.drawImages(
- gl_state,
- self.image_placements.items[self.image_text_end..],
- );
}
-/// Runs the image program to draw images.
-fn drawImages(
- self: *OpenGL,
- gl_state: *const GLState,
- placements: []const gl_image.Placement,
-) !void {
- if (placements.len == 0) return;
-
- // Bind our image program
- const bind = try gl_state.image_program.bind();
- defer bind.unbind();
-
- // For each placement we need to bind the texture
- for (placements) |p| {
- // Get the image and image texture
- const image = self.images.get(p.image_id) orelse {
- log.warn("image not found for placement image_id={}", .{p.image_id});
- continue;
- };
-
- const texture = switch (image.image) {
- .ready => |t| t,
- else => {
- log.warn("image not ready for placement image_id={}", .{p.image_id});
- continue;
- },
+/// Initializes a Texture suitable for the provided font atlas.
+pub fn initAtlasTexture(
+ self: *const OpenGL,
+ atlas: *const font.Atlas,
+) Texture.Error!Texture {
+ _ = self;
+ const format: gl.Texture.Format, const internal_format: gl.Texture.InternalFormat =
+ switch (atlas.format) {
+ .grayscale => .{ .red, .red },
+ .bgra => .{ .bgra, .srgba },
+ else => @panic("unsupported atlas format for OpenGL texture"),
};
- // Bind the texture
- try gl.Texture.active(gl.c.GL_TEXTURE0);
- var texbind = try texture.bind(.@"2D");
- defer texbind.unbind();
-
- // Setup our data
- try bind.vbo.setData(ImageProgram.Input{
- .grid_col = p.x,
- .grid_row = p.y,
- .cell_offset_x = p.cell_offset_x,
- .cell_offset_y = p.cell_offset_y,
- .source_x = p.source_x,
- .source_y = p.source_y,
- .source_width = p.source_width,
- .source_height = p.source_height,
- .dest_width = p.width,
- .dest_height = p.height,
- }, .static_draw);
-
- try gl.drawElementsInstanced(
- gl.c.GL_TRIANGLES,
- 6,
- gl.c.GL_UNSIGNED_BYTE,
- 1,
- );
- }
-}
-
-/// Loads some set of cell data into our buffer and issues a draw call.
-/// This expects all the OpenGL state to be setup.
-///
-/// Future: when we move to multiple shaders, this will go away and
-/// we'll have a draw call per-shader.
-fn drawCells(
- self: *OpenGL,
- gl_state: *const GLState,
- cells: std.ArrayListUnmanaged(CellProgram.Cell),
-) !void {
- // If we have no cells to render, then we render nothing.
- if (cells.items.len == 0) return;
-
- // Todo: get rid of this completely
- self.gl_cells_written = 0;
-
- // Bind our cell program state, buffers
- const bind = try gl_state.cell_program.bind();
- defer bind.unbind();
-
- // Bind our textures
- try gl.Texture.active(gl.c.GL_TEXTURE0);
- var texbind = try gl_state.texture.bind(.@"2D");
- defer texbind.unbind();
-
- try gl.Texture.active(gl.c.GL_TEXTURE1);
- var texbind1 = try gl_state.texture_color.bind(.@"2D");
- defer texbind1.unbind();
-
- // Our allocated buffer on the GPU is smaller than our capacity.
- // We reallocate a new buffer with the full new capacity.
- if (self.gl_cells_size < cells.capacity) {
- log.info("reallocating GPU buffer old={} new={}", .{
- self.gl_cells_size,
- cells.capacity,
- });
-
- try bind.vbo.setDataNullManual(
- @sizeOf(CellProgram.Cell) * cells.capacity,
- .static_draw,
- );
-
- self.gl_cells_size = cells.capacity;
- self.gl_cells_written = 0;
- }
-
- // If we have data to write to the GPU, send it.
- if (self.gl_cells_written < cells.items.len) {
- const data = cells.items[self.gl_cells_written..];
- // log.info("sending {} cells to GPU", .{data.len});
- try bind.vbo.setSubData(self.gl_cells_written * @sizeOf(CellProgram.Cell), data);
-
- self.gl_cells_written += data.len;
- assert(data.len > 0);
- assert(self.gl_cells_written <= cells.items.len);
- }
-
- try gl.drawElementsInstanced(
- gl.c.GL_TRIANGLES,
- 6,
- gl.c.GL_UNSIGNED_BYTE,
- cells.items.len,
+ return try Texture.init(
+ .{
+ .format = format,
+ .internal_format = internal_format,
+ .target = .Rectangle,
+ },
+ atlas.size,
+ atlas.size,
+ null,
);
}
-/// The OpenGL objects that are associated with a renderer. This makes it
-/// easy to create/destroy these as a set in situations i.e. where the
-/// OpenGL context is replaced.
-const GLState = struct {
- cell_program: CellProgram,
- image_program: ImageProgram,
- texture: gl.Texture,
- texture_color: gl.Texture,
- custom: ?custom.State,
-
- pub fn init(
- alloc: Allocator,
- config: DerivedConfig,
- font_grid: *font.SharedGrid,
- ) !GLState {
- var arena = ArenaAllocator.init(alloc);
- defer arena.deinit();
- const arena_alloc = arena.allocator();
-
- // Load our custom shaders
- const custom_state: ?custom.State = custom: {
- const shaders: []const [:0]const u8 = shadertoy.loadFromFiles(
- arena_alloc,
- config.custom_shaders,
- .glsl,
- ) catch |err| err: {
- log.warn("error loading custom shaders err={}", .{err});
- break :err &.{};
- };
- if (shaders.len == 0) break :custom null;
-
- break :custom custom.State.init(
- alloc,
- shaders,
- ) catch |err| err: {
- log.warn("error initializing custom shaders err={}", .{err});
- break :err null;
- };
- };
-
- // Blending for text. We use GL_ONE here because we should be using
- // premultiplied alpha for all our colors in our fragment shaders.
- // This avoids having a blurry border where transparency is expected on
- // pixels.
- try gl.enable(gl.c.GL_BLEND);
- try gl.blendFunc(gl.c.GL_ONE, gl.c.GL_ONE_MINUS_SRC_ALPHA);
-
- // Build our texture
- const tex = try gl.Texture.create();
- errdefer tex.destroy();
- {
- const texbind = try tex.bind(.@"2D");
- try texbind.parameter(.WrapS, gl.c.GL_CLAMP_TO_EDGE);
- try texbind.parameter(.WrapT, gl.c.GL_CLAMP_TO_EDGE);
- try texbind.parameter(.MinFilter, gl.c.GL_LINEAR);
- try texbind.parameter(.MagFilter, gl.c.GL_LINEAR);
- try texbind.image2D(
- 0,
- .red,
- @intCast(font_grid.atlas_grayscale.size),
- @intCast(font_grid.atlas_grayscale.size),
- 0,
- .red,
- .UnsignedByte,
- font_grid.atlas_grayscale.data.ptr,
- );
- }
-
- // Build our color texture
- const tex_color = try gl.Texture.create();
- errdefer tex_color.destroy();
- {
- const texbind = try tex_color.bind(.@"2D");
- try texbind.parameter(.WrapS, gl.c.GL_CLAMP_TO_EDGE);
- try texbind.parameter(.WrapT, gl.c.GL_CLAMP_TO_EDGE);
- try texbind.parameter(.MinFilter, gl.c.GL_LINEAR);
- try texbind.parameter(.MagFilter, gl.c.GL_LINEAR);
- try texbind.image2D(
- 0,
- .rgba,
- @intCast(font_grid.atlas_color.size),
- @intCast(font_grid.atlas_color.size),
- 0,
- .bgra,
- .UnsignedByte,
- font_grid.atlas_color.data.ptr,
- );
- }
-
- // Build our cell renderer
- const cell_program = try CellProgram.init();
- errdefer cell_program.deinit();
-
- // Build our image renderer
- const image_program = try ImageProgram.init();
- errdefer image_program.deinit();
-
- return .{
- .cell_program = cell_program,
- .image_program = image_program,
- .texture = tex,
- .texture_color = tex_color,
- .custom = custom_state,
- };
- }
-
- pub fn deinit(self: *GLState, alloc: Allocator) void {
- if (self.custom) |v| v.deinit(alloc);
- self.texture.destroy();
- self.texture_color.destroy();
- self.image_program.deinit();
- self.cell_program.deinit();
- }
-};
+/// Begin a frame.
+pub inline fn beginFrame(
+ self: *const OpenGL,
+ /// Once the frame has been completed, the `frameCompleted` method
+ /// on the renderer is called with the health status of the frame.
+ renderer: *Renderer,
+ /// The target is presented via the provided renderer's API when completed.
+ target: *Target,
+) !Frame {
+ _ = self;
+ return try Frame.begin(.{}, renderer, target);
+}
diff --git a/src/renderer/Options.zig b/src/renderer/Options.zig
index e7d9b3a42..85ff8e310 100644
--- a/src/renderer/Options.zig
+++ b/src/renderer/Options.zig
@@ -20,3 +20,6 @@ surface_mailbox: apprt.surface.Mailbox,
/// The apprt surface.
rt_surface: *apprt.Surface,
+
+/// The renderer thread.
+thread: *renderer.Thread,
diff --git a/src/renderer/Thread.zig b/src/renderer/Thread.zig
index 46ef8609b..b8884f2fb 100644
--- a/src/renderer/Thread.zig
+++ b/src/renderer/Thread.zig
@@ -20,6 +20,16 @@ const log = std.log.scoped(.renderer_thread);
const DRAW_INTERVAL = 8; // 120 FPS
const CURSOR_BLINK_INTERVAL = 600;
+/// Whether calls to `drawFrame` must be done from the app thread.
+///
+/// If this is `true` then we send a `redraw_surface` message to the apprt
+/// whenever we need to draw instead of calling `drawFrame` directly.
+const must_draw_from_app_thread =
+ if (@hasDecl(apprt.App, "must_draw_from_app_thread"))
+ apprt.App.must_draw_from_app_thread
+ else
+ false;
+
/// The type used for sending messages to the IO thread. For now this is
/// hardcoded with a capacity. We can make this a comptime parameter in
/// the future if we want it configurable.
@@ -155,7 +165,7 @@ pub fn init(
return .{
.alloc = alloc,
- .config = DerivedConfig.init(config),
+ .config = .init(config),
.loop = loop,
.wakeup = wakeup_h,
.stop = stop_h,
@@ -198,6 +208,13 @@ pub fn threadMain(self: *Thread) void {
fn threadMain_(self: *Thread) !void {
defer log.debug("renderer thread exited", .{});
+ // Right now, on Darwin, `std.Thread.setName` can only name the current
+ // thread, and we have no way to get the current thread from within it,
+ // so instead we use this code to name the thread instead.
+ if (builtin.os.tag.isDarwin()) {
+ internal_os.macos.pthread_setname_np(&"renderer".*);
+ }
+
// Setup our crash metadata
crash.sentry.thread_state = .{
.type = .renderer,
@@ -307,6 +324,16 @@ fn stopDrawTimer(self: *Thread) void {
/// Drain the mailbox.
fn drainMailbox(self: *Thread) !void {
+ // There's probably a more elegant way to do this...
+ //
+ // This is effectively an @autoreleasepool{} block, which we need in
+ // order to ensure that autoreleased objects are properly released.
+ const pool = if (builtin.os.tag.isDarwin())
+ @import("objc").AutoreleasePool.init()
+ else
+ void;
+ defer if (builtin.os.tag.isDarwin()) pool.deinit();
+
while (self.mailbox.pop()) |message| {
log.debug("mailbox message={}", .{message});
switch (message) {
@@ -425,7 +452,7 @@ fn drainMailbox(self: *Thread) !void {
self.renderer.markDirty();
},
- .resize => |v| try self.renderer.setScreenSize(v),
+ .resize => |v| self.renderer.setScreenSize(v),
.change_config => |config| {
defer config.alloc.destroy(config.thread);
@@ -461,20 +488,16 @@ fn drawFrame(self: *Thread, now: bool) void {
if (!self.flags.visible) return;
// If the renderer is managing a vsync on its own, we only draw
- // when we're forced to via now.
+ // when we're forced to via `now`.
if (!now and self.renderer.hasVsync()) return;
- // If we're doing single-threaded GPU calls then we just wake up the
- // app thread to redraw at this point.
- if (rendererpkg.Renderer == rendererpkg.OpenGL and
- rendererpkg.OpenGL.single_threaded_draw)
- {
+ if (must_draw_from_app_thread) {
_ = self.app_mailbox.push(
.{ .redraw_surface = self.surface },
.{ .instant = {} },
);
} else {
- self.renderer.drawFrame(self.surface) catch |err|
+ self.renderer.drawFrame(false) catch |err|
log.warn("error drawing err={}", .{err});
}
}
@@ -582,7 +605,6 @@ fn renderCallback(
// Update our frame data
t.renderer.updateFrame(
- t.surface,
t.state,
t.flags.cursor_blink_visible,
) catch |err|
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/cursor.zig b/src/renderer/cursor.zig
index d8769d9e2..287b83450 100644
--- a/src/renderer/cursor.zig
+++ b/src/renderer/cursor.zig
@@ -62,7 +62,7 @@ pub fn style(
}
// Otherwise, we use whatever style the terminal wants.
- return Style.fromTerminal(state.terminal.screen.cursor.cursor_style);
+ return .fromTerminal(state.terminal.screen.cursor.cursor_style);
}
test "cursor: default uses configured style" {
diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig
new file mode 100644
index 000000000..bf189fc4c
--- /dev/null
+++ b/src/renderer/generic.zig
@@ -0,0 +1,3216 @@
+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");
+const os = @import("../os/main.zig");
+const terminal = @import("../terminal/main.zig");
+const renderer = @import("../renderer.zig");
+const math = @import("../math.zig");
+const Surface = @import("../Surface.zig");
+const link = @import("link.zig");
+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;
+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,
+};
+
+const DisplayLink = switch (builtin.os.tag) {
+ .macos => *macos.video.DisplayLink,
+ else => void,
+};
+
+const log = std.log.scoped(.generic_renderer);
+
+/// Create a renderer type with the provided graphics API wrapper.
+///
+/// The graphics API wrapper must provide the interface outlined below.
+/// Specific details for the interfaces are documented on the existing
+/// implementations (`Metal` and `OpenGL`).
+///
+/// Hierarchy of graphics abstractions:
+///
+/// [ GraphicsAPI ] - Responsible for configuring the runtime surface
+/// | | and providing render `Target`s that draw to it,
+/// | | as well as `Frame`s and `Pipeline`s.
+/// | V
+/// | [ Target ] - Represents an abstract target for rendering, which
+/// | could be a surface directly but is also used as an
+/// | abstraction for off-screen frame buffers.
+/// V
+/// [ Frame ] - Represents the context for drawing a given frame,
+/// | provides `RenderPass`es for issuing draw commands
+/// | to, and reports the frame health when complete.
+/// V
+/// [ RenderPass ] - Represents a render pass in a frame, consisting of
+/// : one or more `Step`s applied to the same target(s),
+/// [ Step ] - - - - each describing the input buffers and textures and
+/// : the vertex/fragment functions and geometry to use.
+/// :_ _ _ _ _ _ _ _ _ _/
+/// v
+/// [ Pipeline ] - Describes a vertex and fragment function to be used
+/// for a `Step`; the `GraphicsAPI` is responsible for
+/// these and they should be constructed and cached
+/// ahead of time.
+///
+/// [ Buffer ] - An abstraction over a GPU buffer.
+///
+/// [ Texture ] - An abstraction over a GPU texture.
+///
+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 Shaders = shaderpkg.Shaders;
+
+ /// Allocator that can be used
+ alloc: std.mem.Allocator,
+
+ /// This mutex must be held whenever any state used in `drawFrame` is
+ /// being modified, and also when it's being accessed in `drawFrame`.
+ draw_mutex: std.Thread.Mutex = .{},
+
+ /// The configuration we need derived from the main config.
+ config: DerivedConfig,
+
+ /// The mailbox for communicating with the window.
+ surface_mailbox: apprt.surface.Mailbox,
+
+ /// Current font metrics defining our grid.
+ grid_metrics: font.Metrics,
+
+ /// The size of everything.
+ size: renderer.Size,
+
+ /// True if the window is focused
+ focused: bool,
+
+ /// The foreground color set by an OSC 10 sequence. If unset then
+ /// default_foreground_color is used.
+ foreground_color: ?terminal.color.RGB,
+
+ /// Foreground color set in the user's config file.
+ default_foreground_color: terminal.color.RGB,
+
+ /// The background color set by an OSC 11 sequence. If unset then
+ /// default_background_color is used.
+ background_color: ?terminal.color.RGB,
+
+ /// Background color set in the user's config file.
+ default_background_color: terminal.color.RGB,
+
+ /// The cursor color set by an OSC 12 sequence. If unset then
+ /// default_cursor_color is used.
+ cursor_color: ?terminal.color.RGB,
+
+ /// Default cursor color when no color is set explicitly by an OSC 12 command.
+ /// This is cursor color as set in the user's config, if any. If no cursor color
+ /// is set in the user's config, then the cursor color is determined by the
+ /// current foreground color.
+ default_cursor_color: ?terminal.color.RGB,
+
+ /// When `cursor_color` is null, swap the foreground and background colors of
+ /// the cell under the cursor for the cursor color. Otherwise, use the default
+ /// foreground color as the cursor color.
+ cursor_invert: bool,
+
+ /// The current set of cells to render. This is rebuilt on every frame
+ /// but we keep this around so that we don't reallocate. Each set of
+ /// cells goes into a separate shader.
+ cells: cellpkg.Contents,
+
+ /// The last viewport that we based our rebuild off of. If this changes,
+ /// then we do a full rebuild of the cells. The pointer values in this pin
+ /// are NOT SAFE to read because they may be modified, freed, etc from the
+ /// termio thread. We treat the pointers as integers for comparison only.
+ cells_viewport: ?terminal.Pin = null,
+
+ /// Set to true after rebuildCells is called. This can be used
+ /// to determine if any possible changes have been made to the
+ /// cells for the draw call.
+ cells_rebuilt: bool = false,
+
+ /// The current GPU uniform values.
+ uniforms: shaderpkg.Uniforms,
+
+ /// Custom shader uniform values.
+ custom_shader_uniforms: shadertoy.Uniforms,
+
+ /// Timestamp we rendered out first frame.
+ ///
+ /// This is used when updating custom shader uniforms.
+ first_frame_time: ?std.time.Instant = null,
+
+ /// Timestamp when we rendered out more recent frame.
+ ///
+ /// This is used when updating custom shader uniforms.
+ last_frame_time: ?std.time.Instant = null,
+
+ /// The font structures.
+ font_grid: *font.SharedGrid,
+ font_shaper: font.Shaper,
+ font_shaper_cache: font.ShaperCache,
+
+ /// The images that we may render.
+ images: ImageMap = .{},
+ image_placements: ImagePlacementList = .{},
+ image_bg_end: u32 = 0,
+ 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,
+
+ /// The CVDisplayLink used to drive the rendering loop in
+ /// sync with the display. This is void on platforms that
+ /// don't support a display link.
+ display_link: ?DisplayLink = null,
+
+ /// Health of the most recently completed frame.
+ health: std.atomic.Value(Health) = .{ .raw = .healthy },
+
+ /// Our swap chain (multiple buffering)
+ swap_chain: SwapChain,
+
+ /// This value is used to force-update swap chain targets in the
+ /// event of a config change that requires it (such as blending mode).
+ target_config_modified: usize = 0,
+
+ /// If something happened that requires us to reinitialize our shaders,
+ /// this is set to true so that we can do that whenever possible.
+ reinitialize_shaders: bool = false,
+
+ /// Whether or not we have custom shaders.
+ has_custom_shaders: bool = false,
+
+ /// Our shader pipelines.
+ shaders: Shaders,
+
+ /// Swap chain which maintains multiple copies of the state needed to
+ /// render a frame, so that we can start building the next frame while
+ /// the previous frame is still being processed on the GPU.
+ const SwapChain = struct {
+ // The count of buffers we use for double/triple buffering.
+ // If this is one then we don't do any double+ buffering at all.
+ // This is comptime because there isn't a good reason to change
+ // this at runtime and there is a lot of complexity to support it.
+ const buf_count = GraphicsAPI.swap_chain_count;
+
+ /// `buf_count` structs that can hold the
+ /// data needed by the GPU to draw a frame.
+ frames: [buf_count]FrameState,
+ /// Index of the most recently used frame state struct.
+ frame_index: std.math.IntFittingRange(0, buf_count) = 0,
+ /// Semaphore that we wait on to make sure we have an available
+ /// frame state struct so we can start working on a new frame.
+ frame_sema: std.Thread.Semaphore = .{ .permits = buf_count },
+
+ /// Set to true when deinited, if you try to deinit a defunct
+ /// swap chain it will just be ignored, to prevent double-free.
+ ///
+ /// This is required because of `displayUnrealized`, since it
+ /// `deinits` the swapchain, which leads to a double-free if
+ /// the renderer is deinited after that.
+ defunct: bool = false,
+
+ pub fn init(api: GraphicsAPI, custom_shaders: bool) !SwapChain {
+ var result: SwapChain = .{ .frames = undefined };
+
+ // Initialize all of our frame state.
+ for (&result.frames) |*frame| {
+ frame.* = try FrameState.init(api, custom_shaders);
+ }
+
+ return result;
+ }
+
+ pub fn deinit(self: *SwapChain) void {
+ if (self.defunct) return;
+ self.defunct = true;
+
+ // Wait for all of our inflight draws to complete
+ // so that we can cleanly deinit our GPU state.
+ for (0..buf_count) |_| self.frame_sema.wait();
+ for (&self.frames) |*frame| frame.deinit();
+ }
+
+ /// Get the next frame state to draw to. This will wait on the
+ /// semaphore to ensure that the frame is available. This must
+ /// always be paired with a call to releaseFrame.
+ pub fn nextFrame(self: *SwapChain) error{Defunct}!*FrameState {
+ if (self.defunct) return error.Defunct;
+
+ self.frame_sema.wait();
+ errdefer self.frame_sema.post();
+ self.frame_index = (self.frame_index + 1) % buf_count;
+ return &self.frames[self.frame_index];
+ }
+
+ /// This should be called when the frame has completed drawing.
+ pub fn releaseFrame(self: *SwapChain) void {
+ self.frame_sema.post();
+ }
+ };
+
+ /// State we need duplicated for every frame. Any state that could be
+ /// in a data race between the GPU and CPU while a frame is being drawn
+ /// should be in this struct.
+ ///
+ /// While a draw is in-process, we "lock" the state (via a semaphore)
+ /// and prevent the CPU from updating the state until our graphics API
+ /// reports that the frame is complete.
+ ///
+ /// This is used to implement double/triple buffering.
+ const FrameState = struct {
+ uniforms: UniformBuffer,
+ cells: CellTextBuffer,
+ cells_bg: CellBgBuffer,
+
+ grayscale: Texture,
+ grayscale_modified: usize = 0,
+ color: Texture,
+ color_modified: usize = 0,
+
+ target: Target,
+ /// 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,
+
+ 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
+ // uniform data will be undefined so this must be set before
+ // a frame is drawn.
+ var uniforms = try UniformBuffer.init(api.uniformBufferOptions(), 1);
+ errdefer uniforms.deinit();
+
+ // Create GPU buffers for our cells.
+ //
+ // We start them off with a size of 1, which will of course be
+ // too small, but they will be resized as needed. This is a bit
+ // wasteful but since it's a one-time thing it's not really a
+ // huge concern.
+ var cells = try CellTextBuffer.init(api.fgBufferOptions(), 1);
+ errdefer cells.deinit();
+ 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
+ // as possible since they'll inevitably be resized anyway.
+ const grayscale = try api.initAtlasTexture(&.{
+ .data = undefined,
+ .size = 1,
+ .format = .grayscale,
+ });
+ errdefer grayscale.deinit();
+ const color = try api.initAtlasTexture(&.{
+ .data = undefined,
+ .size = 1,
+ .format = .bgra,
+ });
+ errdefer color.deinit();
+
+ var custom_shader_state =
+ if (custom_shaders)
+ try CustomShaderState.init(api)
+ else
+ null;
+ errdefer if (custom_shader_state) |*state| state.deinit();
+
+ // Initialize the target. Just as with the other resources,
+ // start it off as small as we can since it'll be resized.
+ const target = try api.initTarget(1, 1);
+
+ return .{
+ .uniforms = uniforms,
+ .cells = cells,
+ .cells_bg = cells_bg,
+ .bg_image_buffer = bg_image_buffer,
+ .grayscale = grayscale,
+ .color = color,
+ .target = target,
+ .custom_shader_state = custom_shader_state,
+ };
+ }
+
+ pub fn deinit(self: *FrameState) void {
+ self.uniforms.deinit();
+ self.cells.deinit();
+ self.cells_bg.deinit();
+ self.grayscale.deinit();
+ self.color.deinit();
+ self.bg_image_buffer.deinit();
+ if (self.custom_shader_state) |*state| state.deinit();
+ }
+
+ pub fn resize(
+ self: *FrameState,
+ api: GraphicsAPI,
+ width: usize,
+ height: usize,
+ ) !void {
+ if (self.custom_shader_state) |*state| {
+ try state.resize(api, width, height);
+ }
+ const target = try api.initTarget(width, height);
+ self.target.deinit();
+ self.target = target;
+ }
+ };
+
+ /// State relevant to our custom shaders if we have any.
+ const CustomShaderState = struct {
+ /// When we have a custom shader state, we maintain a front
+ /// and back texture which we use as a swap chain to render
+ /// between when multiple custom shaders are defined.
+ 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(
+ api.textureOptions(),
+ 1,
+ 1,
+ null,
+ );
+ errdefer front_texture.deinit();
+ const back_texture = try Texture.init(
+ api.textureOptions(),
+ 1,
+ 1,
+ 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(
+ self: *CustomShaderState,
+ api: GraphicsAPI,
+ width: usize,
+ height: usize,
+ ) !void {
+ const front_texture = try Texture.init(
+ api.textureOptions(),
+ @intCast(width),
+ @intCast(height),
+ null,
+ );
+ errdefer front_texture.deinit();
+ const back_texture = try Texture.init(
+ api.textureOptions(),
+ @intCast(width),
+ @intCast(height),
+ null,
+ );
+ errdefer back_texture.deinit();
+
+ self.front_texture.deinit();
+ self.back_texture.deinit();
+
+ self.front_texture = front_texture;
+ self.back_texture = back_texture;
+ }
+ };
+
+ /// The configuration for this renderer that is derived from the main
+ /// configuration. This must be exported so that we don't need to
+ /// pass around Config pointers which makes memory management a pain.
+ pub const DerivedConfig = struct {
+ arena: ArenaAllocator,
+
+ font_thicken: bool,
+ font_thicken_strength: u8,
+ font_features: std.ArrayListUnmanaged([:0]const u8),
+ font_styles: font.CodepointResolver.StyleStatus,
+ cursor_color: ?terminal.color.RGB,
+ cursor_invert: bool,
+ cursor_opacity: f64,
+ cursor_text: ?terminal.color.RGB,
+ background: terminal.color.RGB,
+ background_opacity: f64,
+ foreground: terminal.color.RGB,
+ selection_background: ?terminal.color.RGB,
+ selection_foreground: ?terminal.color.RGB,
+ invert_selection_fg_bg: bool,
+ bold_is_bright: bool,
+ 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,
+ blending: configpkg.Config.AlphaBlending,
+
+ pub fn init(
+ alloc_gpa: Allocator,
+ config: *const configpkg.Config,
+ ) !DerivedConfig {
+ var arena = ArenaAllocator.init(alloc_gpa);
+ errdefer arena.deinit();
+ const alloc = arena.allocator();
+
+ // 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);
+
+ // Get our font styles
+ var font_styles = font.CodepointResolver.StyleStatus.initFill(true);
+ font_styles.set(.bold, config.@"font-style-bold" != .false);
+ font_styles.set(.italic, config.@"font-style-italic" != .false);
+ font_styles.set(.bold_italic, config.@"font-style-bold-italic" != .false);
+
+ // Our link configs
+ const links = try link.Set.fromConfig(
+ alloc,
+ config.link.links.items,
+ );
+
+ const cursor_invert = config.@"cursor-invert-fg-bg";
+
+ return .{
+ .background_opacity = @max(0, @min(1, config.@"background-opacity")),
+ .font_thicken = config.@"font-thicken",
+ .font_thicken_strength = config.@"font-thicken-strength",
+ .font_features = font_features.list,
+ .font_styles = font_styles,
+
+ .cursor_color = if (!cursor_invert and config.@"cursor-color" != null)
+ config.@"cursor-color".?.toTerminalRGB()
+ else
+ null,
+
+ .cursor_invert = cursor_invert,
+
+ .cursor_text = if (config.@"cursor-text") |txt|
+ txt.toTerminalRGB()
+ else
+ null,
+
+ .cursor_opacity = @max(0, @min(1, config.@"cursor-opacity")),
+
+ .background = config.background.toTerminalRGB(),
+ .foreground = config.foreground.toTerminalRGB(),
+ .invert_selection_fg_bg = config.@"selection-invert-fg-bg",
+ .bold_is_bright = config.@"bold-is-bright",
+ .min_contrast = @floatCast(config.@"minimum-contrast"),
+ .padding_color = config.@"window-padding-color",
+
+ .selection_background = if (config.@"selection-background") |bg|
+ bg.toTerminalRGB()
+ else
+ null,
+
+ .selection_foreground = if (config.@"selection-foreground") |bg|
+ bg.toTerminalRGB()
+ else
+ 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",
+ .blending = config.@"alpha-blending",
+ .arena = arena,
+ };
+ }
+
+ pub fn deinit(self: *DerivedConfig) void {
+ const alloc = self.arena.allocator();
+ self.links.deinit(alloc);
+ self.arena.deinit();
+ }
+ };
+
+ /// Returns the hints that we want for this window.
+ pub fn glfwWindowHints(config: *const configpkg.Config) glfw.Window.Hints {
+ // If our graphics API provides hints, use them,
+ // otherwise fall back to generic hints.
+ if (@hasDecl(GraphicsAPI, "glfwWindowHints")) {
+ return GraphicsAPI.glfwWindowHints(config);
+ }
+
+ return .{
+ .client_api = .no_api,
+ .transparent_framebuffer = config.@"background-opacity" < 1,
+ };
+ }
+
+ pub fn init(alloc: Allocator, options: renderer.Options) !Self {
+ // Initialize our graphics API wrapper, this will prepare the
+ // surface provided by the apprt and set up any API-specific
+ // GPU resources.
+ var api = try GraphicsAPI.init(alloc, options);
+ errdefer api.deinit();
+
+ const has_custom_shaders = options.config.custom_shaders.value.items.len > 0;
+
+ // Prepare our swap chain
+ var swap_chain = try SwapChain.init(
+ api,
+ has_custom_shaders,
+ );
+ errdefer swap_chain.deinit();
+
+ // Create the font shaper.
+ var font_shaper = try font.Shaper.init(alloc, .{
+ .features = options.config.font_features.items,
+ });
+ errdefer font_shaper.deinit();
+
+ // Initialize all the data that requires a critical font section.
+ const font_critical: struct {
+ metrics: font.Metrics,
+ } = font_critical: {
+ const grid: *font.SharedGrid = options.font_grid;
+ grid.lock.lockShared();
+ defer grid.lock.unlockShared();
+ break :font_critical .{
+ .metrics = grid.metrics,
+ };
+ };
+
+ const display_link: ?DisplayLink = switch (builtin.os.tag) {
+ .macos => if (options.config.vsync)
+ try macos.video.DisplayLink.createWithActiveCGDisplays()
+ else
+ null,
+ else => null,
+ };
+ errdefer if (display_link) |v| v.release();
+
+ var result: Self = .{
+ .alloc = alloc,
+ .config = options.config,
+ .surface_mailbox = options.surface_mailbox,
+ .grid_metrics = font_critical.metrics,
+ .size = options.size,
+ .focused = true,
+ .foreground_color = null,
+ .default_foreground_color = options.config.foreground,
+ .background_color = null,
+ .default_background_color = options.config.background,
+ .cursor_color = null,
+ .default_cursor_color = options.config.cursor_color,
+ .cursor_invert = options.config.cursor_invert,
+
+ // Render state
+ .cells = .{},
+ .uniforms = .{
+ .projection_matrix = undefined,
+ .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) },
+ .cursor_color = undefined,
+ .bg_color = .{
+ options.config.background.r,
+ options.config.background.g,
+ options.config.background.b,
+ @intFromFloat(@round(options.config.background_opacity * 255.0)),
+ },
+ .bools = .{
+ .cursor_wide = false,
+ .use_display_p3 = options.config.colorspace == .@"display-p3",
+ .use_linear_blending = options.config.blending.isLinear(),
+ .use_linear_correction = options.config.blending == .@"linear-corrected",
+ },
+ },
+ .custom_shader_uniforms = .{
+ .resolution = .{ 0, 0, 1 },
+ .time = 0,
+ .time_delta = 0,
+ .frame_rate = 60, // not currently updated
+ .frame = 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
+ .sample_rate = 0, // N/A, we don't have any audio
+ .current_cursor = @splat(0),
+ .previous_cursor = @splat(0),
+ .current_cursor_color = @splat(0),
+ .previous_cursor_color = @splat(0),
+ .cursor_change_time = 0,
+ },
+ .bg_image_buffer = undefined,
+
+ // Fonts
+ .font_grid = options.font_grid,
+ .font_shaper = font_shaper,
+ .font_shaper_cache = font.ShaperCache.init(),
+
+ // Shaders (initialized below)
+ .shaders = undefined,
+
+ // Graphics API stuff
+ .api = api,
+ .swap_chain = swap_chain,
+ .display_link = display_link,
+ };
+
+ try result.initShaders();
+
+ // Ensure our undefined values above are correctly initialized.
+ result.updateFontGridUniforms();
+ result.updateScreenSizeUniforms();
+ result.updateBgImageBuffer();
+ try result.prepBackgroundImage();
+
+ return result;
+ }
+
+ pub fn deinit(self: *Self) void {
+ self.swap_chain.deinit();
+
+ if (DisplayLink != void) {
+ if (self.display_link) |display_link| {
+ display_link.stop() catch {};
+ display_link.release();
+ }
+ }
+
+ self.cells.deinit(self.alloc);
+
+ self.font_shaper.deinit();
+ self.font_shaper_cache.deinit(self.alloc);
+
+ self.config.deinit();
+
+ {
+ var it = self.images.iterator();
+ while (it.next()) |kv| kv.value_ptr.image.deinit(self.alloc);
+ self.images.deinit(self.alloc);
+ }
+ self.image_placements.deinit(self.alloc);
+
+ if (self.bg_image) |img| img.deinit(self.alloc);
+
+ self.deinitShaders();
+
+ self.api.deinit();
+
+ self.* = undefined;
+ }
+
+ fn deinitShaders(self: *Self) void {
+ self.shaders.deinit(self.alloc);
+ }
+
+ fn initShaders(self: *Self) !void {
+ var arena = ArenaAllocator.init(self.alloc);
+ defer arena.deinit();
+ const arena_alloc = arena.allocator();
+
+ // Load our custom shaders
+ const custom_shaders: []const [:0]const u8 = shadertoy.loadFromFiles(
+ arena_alloc,
+ self.config.custom_shaders,
+ GraphicsAPI.custom_shader_target,
+ ) catch |err| err: {
+ log.warn("error loading custom shaders err={}", .{err});
+ break :err &.{};
+ };
+
+ const has_custom_shaders = custom_shaders.len > 0;
+
+ var shaders = try self.api.initShaders(
+ self.alloc,
+ custom_shaders,
+ );
+ errdefer shaders.deinit(self.alloc);
+
+ self.shaders = shaders;
+ self.has_custom_shaders = has_custom_shaders;
+ }
+
+ /// This is called early right after surface creation.
+ pub fn surfaceInit(surface: *apprt.Surface) !void {
+ // If our API has to do things here, let it.
+ if (@hasDecl(GraphicsAPI, "surfaceInit")) {
+ try GraphicsAPI.surfaceInit(surface);
+ }
+ }
+
+ /// This is called just prior to spinning up the renderer thread for
+ /// final main thread setup requirements.
+ pub fn finalizeSurfaceInit(self: *Self, surface: *apprt.Surface) !void {
+ // If our API has to do things to finalize surface init, let it.
+ if (@hasDecl(GraphicsAPI, "finalizeSurfaceInit")) {
+ try self.api.finalizeSurfaceInit(surface);
+ }
+ }
+
+ /// Callback called by renderer.Thread when it begins.
+ pub fn threadEnter(self: *const Self, surface: *apprt.Surface) !void {
+ // If our API has to do things on thread enter, let it.
+ if (@hasDecl(GraphicsAPI, "threadEnter")) {
+ try self.api.threadEnter(surface);
+ }
+ }
+
+ /// Callback called by renderer.Thread when it exits.
+ pub fn threadExit(self: *const Self) void {
+ // If our API has to do things on thread exit, let it.
+ if (@hasDecl(GraphicsAPI, "threadExit")) {
+ self.api.threadExit();
+ }
+ }
+
+ /// Called by renderer.Thread when it starts the main loop.
+ pub fn loopEnter(self: *Self, thr: *renderer.Thread) !void {
+ // If our API has to do things on loop enter, let it.
+ if (@hasDecl(GraphicsAPI, "loopEnter")) {
+ self.api.loopEnter();
+ }
+
+ // If we don't support a display link we have no work to do.
+ if (comptime DisplayLink == void) return;
+
+ // This is when we know our "self" pointer is stable so we can
+ // setup the display link. To setup the display link we set our
+ // callback and we can start it immediately.
+ const display_link = self.display_link orelse return;
+ try display_link.setOutputCallback(
+ xev.Async,
+ &displayLinkCallback,
+ &thr.draw_now,
+ );
+ display_link.start() catch {};
+ }
+
+ /// Called by renderer.Thread when it exits the main loop.
+ pub fn loopExit(self: *Self) void {
+ // If our API has to do things on loop exit, let it.
+ if (@hasDecl(GraphicsAPI, "loopExit")) {
+ self.api.loopExit();
+ }
+
+ // If we don't support a display link we have no work to do.
+ if (comptime DisplayLink == void) return;
+
+ // Stop our display link. If this fails its okay it just means
+ // that we either never started it or the view its attached to
+ // is gone which is fine.
+ const display_link = self.display_link orelse return;
+ display_link.stop() catch {};
+ }
+
+ /// This is called by the GTK apprt after the surface is
+ /// reinitialized due to any of the events mentioned in
+ /// the doc comment for `displayUnrealized`.
+ pub fn displayRealized(self: *Self) !void {
+ // If our API has to do things on realize, let it.
+ if (@hasDecl(GraphicsAPI, "displayRealized")) {
+ self.api.displayRealized();
+ }
+
+ // Lock the draw mutex so that we can
+ // safely reinitialize our GPU resources.
+ self.draw_mutex.lock();
+ defer self.draw_mutex.unlock();
+
+ // We assume that the swap chain was deinited in
+ // `displayUnrealized`, in which case it should be
+ // marked defunct. If not, we have a problem.
+ assert(self.swap_chain.defunct);
+
+ // We reinitialize our shaders and our swap chain.
+ try self.initShaders();
+ self.swap_chain = try SwapChain.init(
+ self.api,
+ self.has_custom_shaders,
+ );
+ self.reinitialize_shaders = false;
+ self.target_config_modified = 1;
+ }
+
+ /// This is called by the GTK apprt when the surface is being destroyed.
+ /// This can happen because the surface is being closed but also when
+ /// moving the window between displays or splitting.
+ pub fn displayUnrealized(self: *Self) void {
+ // If our API has to do things on unrealize, let it.
+ if (@hasDecl(GraphicsAPI, "displayUnrealized")) {
+ self.api.displayUnrealized();
+ }
+
+ // Lock the draw mutex so that we can
+ // safely deinitialize our GPU resources.
+ self.draw_mutex.lock();
+ defer self.draw_mutex.unlock();
+
+ // We deinit our swap chain and shaders.
+ //
+ // This will mark them as defunct so that they
+ // can't be double-freed or used in draw calls.
+ self.swap_chain.deinit();
+ self.shaders.deinit(self.alloc);
+ }
+
+ fn displayLinkCallback(
+ _: *macos.video.DisplayLink,
+ ud: ?*xev.Async,
+ ) void {
+ const draw_now = ud orelse return;
+ draw_now.notify() catch |err| {
+ log.err("error notifying draw_now err={}", .{err});
+ };
+ }
+
+ /// Mark the full screen as dirty so that we redraw everything.
+ pub fn markDirty(self: *Self) void {
+ self.cells_viewport = null;
+ }
+
+ /// Called when we get an updated display ID for our display link.
+ pub fn setMacOSDisplayID(self: *Self, id: u32) !void {
+ if (comptime DisplayLink == void) return;
+ const display_link = self.display_link orelse return;
+ log.info("updating display link display id={}", .{id});
+ display_link.setCurrentCGDisplay(id) catch |err| {
+ log.warn("error setting display link display id err={}", .{err});
+ };
+ }
+
+ /// True if our renderer has animations so that a higher frequency
+ /// timer is used.
+ pub fn hasAnimations(self: *const Self) bool {
+ return self.has_custom_shaders;
+ }
+
+ /// True if our renderer is using vsync. If true, the renderer or apprt
+ /// is responsible for triggering draw_now calls to the render thread.
+ /// That is the only way to trigger a drawFrame.
+ pub fn hasVsync(self: *const Self) bool {
+ if (comptime DisplayLink == void) return false;
+ const display_link = self.display_link orelse return false;
+ return display_link.isRunning();
+ }
+
+ /// Callback when the focus changes for the terminal this is rendering.
+ ///
+ /// Must be called on the render thread.
+ pub fn setFocus(self: *Self, focus: bool) !void {
+ self.focused = focus;
+
+ // If we're not focused, then we want to stop the display link
+ // because it is a waste of resources and we can move to pure
+ // change-driven updates.
+ if (comptime DisplayLink != void) link: {
+ const display_link = self.display_link orelse break :link;
+ if (focus) {
+ display_link.start() catch {};
+ } else {
+ display_link.stop() catch {};
+ }
+ }
+ }
+
+ /// Callback when the window is visible or occluded.
+ ///
+ /// Must be called on the render thread.
+ pub fn setVisible(self: *Self, visible: bool) void {
+ // If we're not visible, then we want to stop the display link
+ // because it is a waste of resources and we can move to pure
+ // change-driven updates.
+ if (comptime DisplayLink != void) link: {
+ const display_link = self.display_link orelse break :link;
+ if (visible and self.focused) {
+ display_link.start() catch {};
+ } else {
+ display_link.stop() catch {};
+ }
+ }
+ }
+
+ /// Set the new font grid.
+ ///
+ /// Must be called on the render thread.
+ pub fn setFontGrid(self: *Self, grid: *font.SharedGrid) void {
+ self.draw_mutex.lock();
+ defer self.draw_mutex.unlock();
+
+ // Update our grid
+ self.font_grid = grid;
+
+ // Update all our textures so that they sync on the next frame.
+ // We can modify this without a lock because the GPU does not
+ // touch this data.
+ for (&self.swap_chain.frames) |*frame| {
+ frame.grayscale_modified = 0;
+ frame.color_modified = 0;
+ }
+
+ // Get our metrics from the grid. This doesn't require a lock because
+ // the metrics are never recalculated.
+ const metrics = grid.metrics;
+ self.grid_metrics = metrics;
+
+ // Reset our shaper cache. If our font changed (not just the size) then
+ // the data in the shaper cache may be invalid and cannot be used, so we
+ // always clear the cache just in case.
+ const font_shaper_cache = font.ShaperCache.init();
+ self.font_shaper_cache.deinit(self.alloc);
+ self.font_shaper_cache = font_shaper_cache;
+
+ // Update cell size.
+ self.size.cell = .{
+ .width = metrics.cell_width,
+ .height = metrics.cell_height,
+ };
+
+ // Update relevant uniforms
+ self.updateFontGridUniforms();
+ }
+
+ /// Update uniforms that are based on the font grid.
+ ///
+ /// Caller must hold the draw mutex.
+ fn updateFontGridUniforms(self: *Self) void {
+ self.uniforms.cell_size = .{
+ @floatFromInt(self.grid_metrics.cell_width),
+ @floatFromInt(self.grid_metrics.cell_height),
+ };
+ }
+
+ /// Update the frame data.
+ pub fn updateFrame(
+ self: *Self,
+ state: *renderer.State,
+ cursor_blink_visible: bool,
+ ) !void {
+ // Data we extract out of the critical area.
+ const Critical = struct {
+ bg: terminal.color.RGB,
+ screen: terminal.Screen,
+ screen_type: terminal.ScreenType,
+ mouse: renderer.State.Mouse,
+ preedit: ?renderer.State.Preedit,
+ cursor_style: ?renderer.CursorStyle,
+ color_palette: terminal.color.Palette,
+
+ /// If true, rebuild the full screen.
+ full_rebuild: bool,
+ };
+
+ // Update all our data as tightly as possible within the mutex.
+ var critical: Critical = critical: {
+ // const start = try std.time.Instant.now();
+ // const start_micro = std.time.microTimestamp();
+ // defer {
+ // const end = std.time.Instant.now() catch unreachable;
+ // // "[updateFrame critical time] <START us>\t<TIME_TAKEN us>"
+ // std.log.err("[updateFrame critical time] {}\t{}", .{start_micro, end.since(start) / std.time.ns_per_us});
+ // }
+
+ state.mutex.lock();
+ defer state.mutex.unlock();
+
+ // If we're in a synchronized output state, we pause all rendering.
+ if (state.terminal.modes.get(.synchronized_output)) {
+ log.debug("synchronized output started, skipping render", .{});
+ return;
+ }
+
+ // Swap bg/fg if the terminal is reversed
+ const bg = self.background_color orelse self.default_background_color;
+ const fg = self.foreground_color orelse self.default_foreground_color;
+ defer {
+ if (self.background_color) |*c| {
+ c.* = bg;
+ } else {
+ self.default_background_color = bg;
+ }
+
+ if (self.foreground_color) |*c| {
+ c.* = fg;
+ } else {
+ self.default_foreground_color = fg;
+ }
+ }
+
+ if (state.terminal.modes.get(.reverse_colors)) {
+ if (self.background_color) |*c| {
+ c.* = fg;
+ } else {
+ self.default_background_color = fg;
+ }
+
+ if (self.foreground_color) |*c| {
+ c.* = bg;
+ } else {
+ self.default_foreground_color = bg;
+ }
+ }
+
+ // Get the viewport pin so that we can compare it to the current.
+ const viewport_pin = state.terminal.screen.pages.pin(.{ .viewport = .{} }).?;
+
+ // We used to share terminal state, but we've since learned through
+ // analysis that it is faster to copy the terminal state than to
+ // hold the lock while rebuilding GPU cells.
+ var screen_copy = try state.terminal.screen.clone(
+ self.alloc,
+ .{ .viewport = .{} },
+ null,
+ );
+ errdefer screen_copy.deinit();
+
+ // Whether to draw our cursor or not.
+ const cursor_style = if (state.terminal.flags.password_input)
+ .lock
+ else
+ renderer.cursorStyle(
+ state,
+ self.focused,
+ cursor_blink_visible,
+ );
+
+ // Get our preedit state
+ const preedit: ?renderer.State.Preedit = preedit: {
+ if (cursor_style == null) break :preedit null;
+ const p = state.preedit orelse break :preedit null;
+ break :preedit try p.clone(self.alloc);
+ };
+ errdefer if (preedit) |p| p.deinit(self.alloc);
+
+ // If we have Kitty graphics data, we enter a SLOW SLOW SLOW path.
+ // We only do this if the Kitty image state is dirty meaning only if
+ // it changes.
+ //
+ // If we have any virtual references, we must also rebuild our
+ // kitty state on every frame because any cell change can move
+ // an image.
+ if (state.terminal.screen.kitty_images.dirty or
+ self.image_virtual)
+ {
+ try self.prepKittyGraphics(state.terminal);
+ }
+
+ // If we have any terminal dirty flags set then we need to rebuild
+ // the entire screen. This can be optimized in the future.
+ const full_rebuild: bool = rebuild: {
+ {
+ const Int = @typeInfo(terminal.Terminal.Dirty).@"struct".backing_integer.?;
+ const v: Int = @bitCast(state.terminal.flags.dirty);
+ if (v > 0) break :rebuild true;
+ }
+ {
+ const Int = @typeInfo(terminal.Screen.Dirty).@"struct".backing_integer.?;
+ const v: Int = @bitCast(state.terminal.screen.dirty);
+ if (v > 0) break :rebuild true;
+ }
+
+ // If our viewport changed then we need to rebuild the entire
+ // screen because it means we scrolled. If we have no previous
+ // viewport then we must rebuild.
+ const prev_viewport = self.cells_viewport orelse break :rebuild true;
+ if (!prev_viewport.eql(viewport_pin)) break :rebuild true;
+
+ break :rebuild false;
+ };
+
+ // Reset the dirty flags in the terminal and screen. We assume
+ // that our rebuild will be successful since so we optimize for
+ // success and reset while we hold the lock. This is much easier
+ // than coordinating row by row or as changes are persisted.
+ state.terminal.flags.dirty = .{};
+ state.terminal.screen.dirty = .{};
+ {
+ var it = state.terminal.screen.pages.pageIterator(
+ .right_down,
+ .{ .screen = .{} },
+ null,
+ );
+ while (it.next()) |chunk| {
+ var dirty_set = chunk.node.data.dirtyBitSet();
+ dirty_set.unsetAll();
+ }
+ }
+
+ // Update our viewport pin
+ self.cells_viewport = viewport_pin;
+
+ break :critical .{
+ .bg = self.background_color orelse self.default_background_color,
+ .screen = screen_copy,
+ .screen_type = state.terminal.active_screen,
+ .mouse = state.mouse,
+ .preedit = preedit,
+ .cursor_style = cursor_style,
+ .color_palette = state.terminal.color_palette.colors,
+ .full_rebuild = full_rebuild,
+ };
+ };
+ defer {
+ critical.screen.deinit();
+ if (critical.preedit) |p| p.deinit(self.alloc);
+ }
+
+ // Build our GPU cells
+ try self.rebuildCells(
+ critical.full_rebuild,
+ &critical.screen,
+ critical.screen_type,
+ critical.mouse,
+ critical.preedit,
+ critical.cursor_style,
+ &critical.color_palette,
+ );
+
+ // Notify our shaper we're done for the frame. For some shapers,
+ // such as CoreText, this triggers off-thread cleanup logic.
+ self.font_shaper.endFrame();
+
+ // Acquire the draw mutex because we're modifying state here.
+ {
+ self.draw_mutex.lock();
+ defer self.draw_mutex.unlock();
+
+ // Update our background color
+ self.uniforms.bg_color = .{
+ critical.bg.r,
+ critical.bg.g,
+ critical.bg.b,
+ @intFromFloat(@round(self.config.background_opacity * 255.0)),
+ };
+ }
+ }
+
+ /// Draw the frame to the screen.
+ ///
+ /// If `sync` is true, this will synchronously block until
+ /// the frame is finished drawing and has been presented.
+ pub fn drawFrame(
+ self: *Self,
+ sync: bool,
+ ) !void {
+ // We hold a the draw mutex to prevent changes to any
+ // data we access while we're in the middle of drawing.
+ self.draw_mutex.lock();
+ defer self.draw_mutex.unlock();
+
+ // Let our graphics API do any bookkeeping, etc.
+ // that it needs to do before / after `drawFrame`.
+ self.api.drawFrameStart();
+ defer self.api.drawFrameEnd();
+
+ // Retrieve the most up-to-date surface size from the Graphics API
+ const surface_size = try self.api.surfaceSize();
+
+ // If either of our surface dimensions is zero
+ // then drawing is absurd, so we just return.
+ if (surface_size.width == 0 or surface_size.height == 0) return;
+
+ const size_changed =
+ self.size.screen.width != surface_size.width or
+ self.size.screen.height != surface_size.height;
+
+ // Conditions under which we need to draw the frame, otherwise we
+ // don't need to since the previous frame should be identical.
+ const needs_redraw =
+ size_changed or
+ self.cells_rebuilt or
+ self.hasAnimations() or
+ sync;
+
+ if (!needs_redraw) {
+ // We still need to present the last target again, because the
+ // apprt may be swapping buffers and display an outdated frame
+ // if we don't draw something new.
+ try self.api.presentLastTarget();
+ return;
+ }
+ self.cells_rebuilt = false;
+
+ // Wait for a frame to be available.
+ const frame = try self.swap_chain.nextFrame();
+ errdefer self.swap_chain.releaseFrame();
+ // log.debug("drawing frame index={}", .{self.swap_chain.frame_index});
+
+ // If we need to reinitialize our shaders, do so.
+ if (self.reinitialize_shaders) {
+ self.reinitialize_shaders = false;
+ self.shaders.deinit(self.alloc);
+ try self.initShaders();
+ }
+
+ // Our shaders should not be defunct at this point.
+ assert(!self.shaders.defunct);
+
+ // If we have custom shaders, make sure we have the
+ // custom shader state in our frame state, otherwise
+ // if we have a state but don't need it we remove it.
+ if (self.has_custom_shaders) {
+ if (frame.custom_shader_state == null) {
+ frame.custom_shader_state = try .init(self.api);
+ try frame.custom_shader_state.?.resize(
+ self.api,
+ surface_size.width,
+ surface_size.height,
+ );
+ }
+ } else if (frame.custom_shader_state) |*state| {
+ state.deinit();
+ frame.custom_shader_state = null;
+ }
+
+ // If our stored size doesn't match the
+ // surface size we need to update it.
+ if (size_changed) {
+ self.size.screen = .{
+ .width = surface_size.width,
+ .height = surface_size.height,
+ };
+ self.updateScreenSizeUniforms();
+ }
+
+ // If this frame's target isn't the correct size, or the target
+ // config has changed (such as when the blending mode changes),
+ // remove it and replace it with a new one with the right values.
+ if (frame.target.width != self.size.screen.width or
+ frame.target.height != self.size.screen.height or
+ frame.target_config_modified != self.target_config_modified)
+ {
+ try frame.resize(
+ self.api,
+ self.size.screen.width,
+ self.size.screen.height,
+ );
+ frame.target_config_modified = self.target_config_modified;
+ }
+
+ // Upload images to the GPU as necessary.
+ try self.uploadKittyImages();
+
+ // Upload the background image to the GPU as necessary.
+ try self.uploadBackgroundImage();
+
+ // Update custom shader uniforms if necessary.
+ try self.updateCustomShaderUniforms();
+
+ // Setup our frame data
+ try frame.uniforms.sync(&.{self.uniforms});
+ 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);
+ if (modified <= frame.grayscale_modified) break :texture;
+ self.font_grid.lock.lockShared();
+ defer self.font_grid.lock.unlockShared();
+ frame.grayscale_modified = self.font_grid.atlas_grayscale.modified.load(.monotonic);
+ try self.syncAtlasTexture(&self.font_grid.atlas_grayscale, &frame.grayscale);
+ }
+ texture: {
+ const modified = self.font_grid.atlas_color.modified.load(.monotonic);
+ if (modified <= frame.color_modified) break :texture;
+ self.font_grid.lock.lockShared();
+ defer self.font_grid.lock.unlockShared();
+ frame.color_modified = self.font_grid.atlas_color.modified.load(.monotonic);
+ try self.syncAtlasTexture(&self.font_grid.atlas_color, &frame.color);
+ }
+
+ // Get a frame context from the graphics API.
+ var frame_ctx = try self.api.beginFrame(self, &frame.target);
+ defer frame_ctx.complete(sync);
+
+ {
+ var pass = frame_ctx.renderPass(&.{.{
+ .target = if (frame.custom_shader_state) |state|
+ .{ .texture = state.back_texture }
+ else
+ .{ .target = frame.target },
+ .clear_color = .{ 0.0, 0.0, 0.0, 0.0 },
+ }});
+ defer pass.complete();
+
+ // 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.pipelines.cell_bg,
+ .uniforms = frame.uniforms.buffer,
+ .buffers = &.{ null, frame.cells_bg.buffer },
+ .draw = .{ .type = .triangle, .vertex_count = 3 },
+ });
+
+ // 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.pipelines.cell_text,
+ .uniforms = frame.uniforms.buffer,
+ .buffers = &.{
+ frame.cells.buffer,
+ frame.cells_bg.buffer,
+ },
+ .textures = &.{
+ frame.grayscale,
+ frame.color,
+ },
+ .draw = .{
+ .type = .triangle_strip,
+ .vertex_count = 4,
+ .instance_count = fg_count,
+ },
+ });
+
+ // 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| {
+ // Sync our uniforms.
+ try state.uniforms.sync(&.{self.custom_shader_uniforms});
+
+ for (self.shaders.post_pipelines, 0..) |pipeline, i| {
+ defer state.swap();
+
+ var pass = frame_ctx.renderPass(&.{.{
+ .target = if (i < self.shaders.post_pipelines.len - 1)
+ .{ .texture = state.front_texture }
+ else
+ .{ .target = frame.target },
+ .clear_color = .{ 0.0, 0.0, 0.0, 0.0 },
+ }});
+ defer pass.complete();
+
+ pass.step(.{
+ .pipeline = pipeline,
+ .uniforms = state.uniforms.buffer,
+ .textures = &.{state.back_texture},
+ .draw = .{
+ .type = .triangle,
+ .vertex_count = 3,
+ },
+ });
+ }
+ }
+ }
+
+ // Callback from the graphics API when a frame is completed.
+ pub fn frameCompleted(
+ self: *Self,
+ health: Health,
+ ) void {
+ // If our health value hasn't changed, then we do nothing. We don't
+ // do a cmpxchg here because strict atomicity isn't important.
+ if (self.health.load(.seq_cst) != health) {
+ self.health.store(health, .seq_cst);
+
+ // Our health value changed, so we notify the surface so that it
+ // can do something about it.
+ _ = self.surface_mailbox.push(.{
+ .renderer_health = health,
+ }, .{ .forever = {} });
+ }
+
+ // Always release our semaphore
+ self.swap_chain.releaseFrame();
+ }
+
+ fn drawImagePlacements(
+ self: *Self,
+ pass: *RenderPass,
+ placements: []const imagepkg.Placement,
+ ) !void {
+ if (placements.len == 0) return;
+
+ for (placements) |p| {
+
+ // Look up the image
+ const image = self.images.get(p.image_id) orelse {
+ log.warn("image not found for placement image_id={}", .{p.image_id});
+ return;
+ };
+
+ // Get the texture
+ const texture = switch (image.image) {
+ .ready => |t| t,
+ else => {
+ log.warn("image not ready for placement image_id={}", .{p.image_id});
+ return;
+ },
+ };
+
+ // Create our vertex buffer, which is always exactly one item.
+ // future(mitchellh): we can group rendering multiple instances of a single image
+ var buf = try Buffer(shaderpkg.Image).initFill(
+ self.api.imageBufferOptions(),
+ &.{.{
+ .grid_pos = .{
+ @as(f32, @floatFromInt(p.x)),
+ @as(f32, @floatFromInt(p.y)),
+ },
+
+ .cell_offset = .{
+ @as(f32, @floatFromInt(p.cell_offset_x)),
+ @as(f32, @floatFromInt(p.cell_offset_y)),
+ },
+
+ .source_rect = .{
+ @as(f32, @floatFromInt(p.source_x)),
+ @as(f32, @floatFromInt(p.source_y)),
+ @as(f32, @floatFromInt(p.source_width)),
+ @as(f32, @floatFromInt(p.source_height)),
+ },
+
+ .dest_size = .{
+ @as(f32, @floatFromInt(p.width)),
+ @as(f32, @floatFromInt(p.height)),
+ },
+ }},
+ );
+ defer buf.deinit();
+
+ pass.step(.{
+ .pipeline = self.shaders.pipelines.image,
+ .buffers = &.{buf.buffer},
+ .textures = &.{texture},
+ .draw = .{
+ .type = .triangle_strip,
+ .vertex_count = 4,
+ },
+ });
+ }
+ }
+
+ /// This goes through the Kitty graphic placements and accumulates the
+ /// placements we need to render on our viewport.
+ fn prepKittyGraphics(
+ self: *Self,
+ t: *terminal.Terminal,
+ ) !void {
+ self.draw_mutex.lock();
+ defer self.draw_mutex.unlock();
+
+ const storage = &t.screen.kitty_images;
+ defer storage.dirty = false;
+
+ // We always clear our previous placements no matter what because
+ // we rebuild them from scratch.
+ self.image_placements.clearRetainingCapacity();
+ self.image_virtual = false;
+
+ // Go through our known images and if there are any that are no longer
+ // in use then mark them to be freed.
+ //
+ // This never conflicts with the below because a placement can't
+ // reference an image that doesn't exist.
+ {
+ var it = self.images.iterator();
+ while (it.next()) |kv| {
+ if (storage.imageById(kv.key_ptr.*) == null) {
+ kv.value_ptr.image.markForUnload();
+ }
+ }
+ }
+
+ // The top-left and bottom-right corners of our viewport in screen
+ // points. This lets us determine offsets and containment of placements.
+ const top = t.screen.pages.getTopLeft(.viewport);
+ const bot = t.screen.pages.getBottomRight(.viewport).?;
+ 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
+ // 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;
+
+ // Special logic based on location
+ switch (p.location) {
+ .pin => {},
+ .virtual => {
+ // We need to mark virtual placements on our renderer so that
+ // we know to rebuild in more scenarios since cell changes can
+ // now trigger placement changes.
+ self.image_virtual = true;
+
+ // We also continue out because virtual placements are
+ // only triggered by the unicode placeholder, not by the
+ // placement itself.
+ continue;
+ },
+ }
+
+ // Get the image for the placement
+ const image = storage.imageById(kv.key_ptr.image_id) orelse {
+ log.warn(
+ "missing image for placement, ignoring image_id={}",
+ .{kv.key_ptr.image_id},
+ );
+ continue;
+ };
+
+ try self.prepKittyPlacement(t, top_y, bot_y, &image, p);
+ }
+
+ // If we have virtual placements then we need to scan for placeholders.
+ if (self.image_virtual) {
+ var v_it = terminal.kitty.graphics.unicode.placementIterator(top, bot);
+ while (v_it.next()) |virtual_p| try self.prepKittyVirtualPlacement(
+ t,
+ &virtual_p,
+ );
+ }
+
+ // Sort the placements by their Z value.
+ std.mem.sortUnstable(
+ imagepkg.Placement,
+ self.image_placements.items,
+ {},
+ struct {
+ fn lessThan(
+ ctx: void,
+ lhs: imagepkg.Placement,
+ rhs: imagepkg.Placement,
+ ) bool {
+ _ = ctx;
+ return lhs.z < rhs.z or (lhs.z == rhs.z and lhs.image_id < rhs.image_id);
+ }
+ }.lessThan,
+ );
+
+ // 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;
+ for (self.image_placements.items, 0..) |p, i| {
+ if (bg_end == null and p.z >= bg_limit) {
+ bg_end = @intCast(i);
+ }
+ if (text_end == null and p.z >= 0) {
+ text_end = @intCast(i);
+ }
+ }
+
+ // 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(
+ self: *Self,
+ t: *terminal.Terminal,
+ p: *const terminal.kitty.graphics.unicode.Placement,
+ ) !void {
+ const storage = &t.screen.kitty_images;
+ const image = storage.imageById(p.image_id) orelse {
+ log.warn(
+ "missing image for virtual placement, ignoring image_id={}",
+ .{p.image_id},
+ );
+ return;
+ };
+
+ const rp = p.renderPlacement(
+ storage,
+ &image,
+ self.grid_metrics.cell_width,
+ self.grid_metrics.cell_height,
+ ) catch |err| {
+ log.warn("error rendering virtual placement err={}", .{err});
+ return;
+ };
+
+ // If our placement is zero sized then we don't do anything.
+ if (rp.dest_width == 0 or rp.dest_height == 0) return;
+
+ const viewport: terminal.point.Point = t.screen.pages.pointFromPin(
+ .viewport,
+ rp.top_left,
+ ) orelse {
+ // This is unreachable with virtual placements because we should
+ // only ever be looking at virtual placements that are in our
+ // viewport in the renderer and virtual placements only ever take
+ // up one row.
+ unreachable;
+ };
+
+ // 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,
+ .x = @intCast(rp.top_left.x),
+ .y = @intCast(viewport.viewport.y),
+ .z = -1,
+ .width = rp.dest_width,
+ .height = rp.dest_height,
+ .cell_offset_x = rp.offset_x,
+ .cell_offset_y = rp.offset_y,
+ .source_x = rp.source_x,
+ .source_y = rp.source_y,
+ .source_width = rp.source_width,
+ .source_height = rp.source_height,
+ });
+ }
+
+ /// Get the viewport-relative position for this
+ /// placement and add it to the placements list.
+ fn prepKittyPlacement(
+ self: *Self,
+ t: *terminal.Terminal,
+ top_y: u32,
+ bot_y: u32,
+ image: *const terminal.kitty.graphics.Image,
+ p: *const terminal.kitty.graphics.ImageStorage.Placement,
+ ) !void {
+ // Get the rect for the placement. If this placement doesn't have
+ // a rect then its virtual or something so skip it.
+ const rect = p.rect(image.*, t) orelse return;
+
+ // This is expensive but necessary.
+ const img_top_y = t.screen.pages.pointFromPin(.screen, rect.top_left).?.screen.y;
+ const img_bot_y = t.screen.pages.pointFromPin(.screen, rect.bottom_right).?.screen.y;
+
+ // If the selection isn't within our viewport then skip it.
+ 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.
+ try self.prepKittyImage(image);
+
+ // Calculate the dimensions of our image, taking in to
+ // account the rows / columns specified by the placement.
+ const dest_size = p.calculatedSize(image.*, t);
+
+ // Calculate the source rectangle
+ const source_x = @min(image.width, p.source_x);
+ const source_y = @min(image.height, p.source_y);
+ const source_width = if (p.source_width > 0)
+ @min(image.width - source_x, p.source_width)
+ else
+ image.width;
+ const source_height = if (p.source_height > 0)
+ @min(image.height - source_y, p.source_height)
+ else
+ image.height;
+
+ // Get the viewport-relative Y position of the placement.
+ const y_pos: i32 = @as(i32, @intCast(img_top_y)) - @as(i32, @intCast(top_y));
+
+ // Accumulate the placement
+ if (dest_size.width > 0 and dest_size.height > 0) {
+ try self.image_placements.append(self.alloc, .{
+ .image_id = image.id,
+ .x = @intCast(rect.top_left.x),
+ .y = y_pos,
+ .z = p.z,
+ .width = dest_size.width,
+ .height = dest_size.height,
+ .cell_offset_x = p.x_offset,
+ .cell_offset_y = p.y_offset,
+ .source_x = source_x,
+ .source_y = source_y,
+ .source_width = source_width,
+ .source_height = source_height,
+ });
+ }
+ }
+
+ /// 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,
+ ) !void {
+ // If this image exists and its transmit time is the same we assume
+ // it is the identical image so we don't need to send it to the GPU.
+ const gop = try self.images.getOrPut(self.alloc, image.id);
+ if (gop.found_existing and
+ gop.value_ptr.transmit_time.order(image.transmit_time) == .eq)
+ {
+ return;
+ }
+
+ // Copy the data into the pending state.
+ const data = try self.alloc.dupe(u8, image.data);
+ errdefer self.alloc.free(data);
+
+ // Store it in the map
+ 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 = .{ .pending = pending };
+
+ if (!gop.found_existing) {
+ gop.value_ptr.* = .{
+ .image = new_image,
+ .transmit_time = undefined,
+ };
+ } else {
+ try gop.value_ptr.image.markForReplace(
+ self.alloc,
+ new_image,
+ );
+ }
+
+ 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();
+ defer self.draw_mutex.unlock();
+
+ // We always redo the font shaper in case font features changed. We
+ // could check to see if there was an actual config change but this is
+ // easier and rare enough to not cause performance issues.
+ {
+ var font_shaper = try font.Shaper.init(self.alloc, .{
+ .features = config.font_features.items,
+ });
+ errdefer font_shaper.deinit();
+ self.font_shaper.deinit();
+ self.font_shaper = font_shaper;
+ }
+
+ // We also need to reset the shaper cache so shaper info
+ // from the previous font isn't re-used for the new font.
+ const font_shaper_cache = font.ShaperCache.init();
+ self.font_shaper_cache.deinit(self.alloc);
+ self.font_shaper_cache = font_shaper_cache;
+
+ // Set our new minimum contrast
+ self.uniforms.min_contrast = config.min_contrast;
+
+ // Set our new color space and blending
+ self.uniforms.bools.use_display_p3 = config.colorspace == .@"display-p3";
+ self.uniforms.bools.use_linear_blending = config.blending.isLinear();
+ self.uniforms.bools.use_linear_correction = config.blending == .@"linear-corrected";
+
+ // Set our new colors
+ self.default_background_color = config.background;
+ self.default_foreground_color = config.foreground;
+ 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;
+
+ const blending_changed = old_blending != config.blending;
+
+ if (blending_changed) {
+ // We update our API's blending mode.
+ self.api.blending = config.blending;
+ // And indicate that we need to reinitialize our shaders.
+ self.reinitialize_shaders = true;
+ // And indicate that our swap chain targets need to
+ // be re-created to account for the new blending mode.
+ self.target_config_modified +%= 1;
+ }
+
+ if (custom_shaders_changed) {
+ self.reinitialize_shaders = true;
+ }
+ }
+
+ /// Resize the screen.
+ pub fn setScreenSize(
+ self: *Self,
+ size: renderer.Size,
+ ) void {
+ self.draw_mutex.lock();
+ defer self.draw_mutex.unlock();
+
+ // We only actually need the padding from this,
+ // everything else is derived elsewhere.
+ self.size.padding = size.padding;
+
+ self.updateScreenSizeUniforms();
+
+ log.debug("screen size size={}", .{size});
+ }
+
+ /// Update uniforms that are based on the screen size.
+ ///
+ /// Caller must hold the draw mutex.
+ fn updateScreenSizeUniforms(self: *Self) void {
+ const terminal_size = self.size.terminal();
+
+ // Blank space around the grid.
+ const blank: renderer.Padding = self.size.screen.blankPadding(
+ self.size.padding,
+ .{
+ .columns = self.cells.size.columns,
+ .rows = self.cells.size.rows,
+ },
+ .{
+ .width = self.grid_metrics.cell_width,
+ .height = self.grid_metrics.cell_height,
+ },
+ ).add(self.size.padding);
+
+ // Setup our uniforms
+ self.uniforms.projection_matrix = math.ortho2d(
+ -1 * @as(f32, @floatFromInt(self.size.padding.left)),
+ @floatFromInt(terminal_size.width + self.size.padding.right),
+ @floatFromInt(terminal_size.height + self.size.padding.bottom),
+ -1 * @as(f32, @floatFromInt(self.size.padding.top)),
+ );
+ self.uniforms.grid_padding = .{
+ @floatFromInt(blank.top),
+ @floatFromInt(blank.right),
+ @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 {
+ // We only need to do this if we have custom shaders.
+ if (!self.has_custom_shaders) return;
+
+ const now = try std.time.Instant.now();
+ defer self.last_frame_time = now;
+ const first_frame_time = self.first_frame_time orelse t: {
+ self.first_frame_time = now;
+ break :t now;
+ };
+ const last_frame_time = self.last_frame_time orelse now;
+
+ const since_ns: f32 = @floatFromInt(now.since(first_frame_time));
+ self.custom_shader_uniforms.time = since_ns / std.time.ns_per_s;
+
+ const delta_ns: f32 = @floatFromInt(now.since(last_frame_time));
+ self.custom_shader_uniforms.time_delta = delta_ns / std.time.ns_per_s;
+
+ self.custom_shader_uniforms.frame += 1;
+
+ const screen = self.size.screen;
+ const padding = self.size.padding;
+ const cell = self.size.cell;
+
+ self.custom_shader_uniforms.resolution = .{
+ @floatFromInt(screen.width),
+ @floatFromInt(screen.height),
+ 1,
+ };
+ self.custom_shader_uniforms.channel_resolution[0] = .{
+ @floatFromInt(screen.width),
+ @floatFromInt(screen.height),
+ 1,
+ 0,
+ };
+
+ // Update custom cursor uniforms, if we have a cursor.
+ if (self.cells.fg_rows.lists[0].items.len > 0) {
+ const cursor: shaderpkg.CellText =
+ self.cells.fg_rows.lists[0].items[0];
+
+ const cursor_width: f32 = @floatFromInt(cursor.glyph_size[0]);
+ const cursor_height: f32 = @floatFromInt(cursor.glyph_size[1]);
+
+ var pixel_x: f32 = @floatFromInt(
+ cursor.grid_pos[0] * cell.width + padding.left,
+ );
+ var pixel_y: f32 = @floatFromInt(
+ cursor.grid_pos[1] * cell.height + padding.top,
+ );
+
+ pixel_x += @floatFromInt(cursor.bearings[0]);
+ pixel_y += @floatFromInt(cursor.bearings[1]);
+
+ // If +Y is up in our shaders, we need to flip the coordinate.
+ if (!GraphicsAPI.custom_shader_y_is_down) {
+ pixel_y = @as(f32, @floatFromInt(screen.height)) - pixel_y;
+ // We need to add the cursor height because we need the +Y
+ // edge for the Y coordinate, and flipping means that it's
+ // the -Y edge now.
+ pixel_y += cursor_height;
+ }
+
+ const new_cursor: [4]f32 = .{
+ pixel_x,
+ pixel_y,
+ cursor_width,
+ cursor_height,
+ };
+ const cursor_color: [4]f32 = .{
+ @as(f32, @floatFromInt(cursor.color[0])) / 255.0,
+ @as(f32, @floatFromInt(cursor.color[1])) / 255.0,
+ @as(f32, @floatFromInt(cursor.color[2])) / 255.0,
+ @as(f32, @floatFromInt(cursor.color[3])) / 255.0,
+ };
+
+ const uniforms = &self.custom_shader_uniforms;
+
+ const cursor_changed: bool =
+ !std.meta.eql(new_cursor, uniforms.current_cursor) or
+ !std.meta.eql(cursor_color, uniforms.current_cursor_color);
+
+ if (cursor_changed) {
+ uniforms.previous_cursor = uniforms.current_cursor;
+ uniforms.previous_cursor_color = uniforms.current_cursor_color;
+ uniforms.current_cursor = new_cursor;
+ uniforms.current_cursor_color = cursor_color;
+ uniforms.cursor_change_time = uniforms.time;
+ }
+ }
+ }
+
+ /// Convert the terminal state to GPU cells stored in CPU memory. These
+ /// are then synced to the GPU in the next frame. This only updates CPU
+ /// memory and doesn't touch the GPU.
+ fn rebuildCells(
+ self: *Self,
+ wants_rebuild: bool,
+ screen: *terminal.Screen,
+ screen_type: terminal.ScreenType,
+ mouse: renderer.State.Mouse,
+ preedit: ?renderer.State.Preedit,
+ cursor_style_: ?renderer.CursorStyle,
+ color_palette: *const terminal.color.Palette,
+ ) !void {
+ self.draw_mutex.lock();
+ defer self.draw_mutex.unlock();
+
+ // const start = try std.time.Instant.now();
+ // const start_micro = std.time.microTimestamp();
+ // defer {
+ // const end = std.time.Instant.now() catch unreachable;
+ // // "[rebuildCells time] <START us>\t<TIME_TAKEN us>"
+ // std.log.warn("[rebuildCells time] {}\t{}", .{start_micro, end.since(start) / std.time.ns_per_us});
+ // }
+
+ _ = screen_type; // we might use this again later so not deleting it yet
+
+ // Create an arena for all our temporary allocations while rebuilding
+ var arena = ArenaAllocator.init(self.alloc);
+ defer arena.deinit();
+ const arena_alloc = arena.allocator();
+
+ // Create our match set for the links.
+ var link_match_set: link.MatchSet = if (mouse.point) |mouse_pt| try self.config.links.matchSet(
+ arena_alloc,
+ screen,
+ mouse_pt,
+ mouse.mods,
+ ) else .{};
+
+ // Determine our x/y range for preedit. We don't want to render anything
+ // here because we will render the preedit separately.
+ const preedit_range: ?struct {
+ y: terminal.size.CellCountInt,
+ x: [2]terminal.size.CellCountInt,
+ cp_offset: usize,
+ } = if (preedit) |preedit_v| preedit: {
+ const range = preedit_v.range(screen.cursor.x, screen.pages.cols - 1);
+ break :preedit .{
+ .y = screen.cursor.y,
+ .x = .{ range.start, range.end },
+ .cp_offset = range.cp_offset,
+ };
+ } else null;
+
+ const grid_size_diff =
+ self.cells.size.rows != screen.pages.rows or
+ self.cells.size.columns != screen.pages.cols;
+
+ if (grid_size_diff) {
+ var new_size = self.cells.size;
+ new_size.rows = screen.pages.rows;
+ new_size.columns = screen.pages.cols;
+ try self.cells.resize(self.alloc, new_size);
+
+ // Update our uniforms accordingly, otherwise
+ // our background cells will be out of place.
+ self.uniforms.grid_size = .{ new_size.columns, new_size.rows };
+ }
+
+ const rebuild = wants_rebuild or grid_size_diff;
+
+ if (rebuild) {
+ // If we are doing a full rebuild, then we clear the entire cell buffer.
+ self.cells.reset();
+
+ // We also reset our padding extension depending on the screen type
+ switch (self.config.padding_color) {
+ .background => {},
+
+ // For extension, assume we are extending in all directions.
+ // For "extend" this may be disabled due to heuristics below.
+ .extend, .@"extend-always" => {
+ self.uniforms.padding_extend = .{
+ .up = true,
+ .down = true,
+ .left = true,
+ .right = true,
+ };
+ },
+ }
+ }
+
+ // We rebuild the cells row-by-row because we
+ // do font shaping and dirty tracking by row.
+ var row_it = screen.pages.rowIterator(.left_up, .{ .viewport = .{} }, null);
+ // If our cell contents buffer is shorter than the screen viewport,
+ // we render the rows that fit, starting from the bottom. If instead
+ // the viewport is shorter than the cell contents buffer, we align
+ // the top of the viewport with the top of the contents buffer.
+ var y: terminal.size.CellCountInt = @min(
+ screen.pages.rows,
+ self.cells.size.rows,
+ );
+ while (row_it.next()) |row| {
+ // The viewport may have more rows than our cell contents,
+ // so we need to break from the loop early if we hit y = 0.
+ if (y == 0) break;
+
+ y -= 1;
+
+ if (!rebuild) {
+ // Only rebuild if we are doing a full rebuild or this row is dirty.
+ if (!row.isDirty()) continue;
+
+ // Clear the cells if the row is dirty
+ self.cells.clear(y);
+ }
+
+ // True if we want to do font shaping around the cursor.
+ // We want to do font shaping as long as the cursor is enabled.
+ const shape_cursor = screen.viewportIsBottom() and
+ y == screen.cursor.y;
+
+ // We need to get this row's selection, if
+ // there is one, for proper run splitting.
+ const row_selection = sel: {
+ const sel = screen.selection orelse break :sel null;
+ const pin = screen.pages.pin(.{ .viewport = .{ .y = y } }) orelse
+ break :sel null;
+ break :sel sel.containedRow(screen, pin) orelse null;
+ };
+
+ // On primary screen, we still apply vertical padding
+ // extension under certain conditions we feel are safe.
+ //
+ // This helps make some scenarios look better while
+ // avoiding scenarios we know do NOT look good.
+ switch (self.config.padding_color) {
+ // These already have the correct values set above.
+ .background, .@"extend-always" => {},
+
+ // Apply heuristics for padding extension.
+ .extend => if (y == 0) {
+ self.uniforms.padding_extend.up = !row.neverExtendBg(
+ color_palette,
+ self.background_color orelse self.default_background_color,
+ );
+ } else if (y == self.cells.size.rows - 1) {
+ self.uniforms.padding_extend.down = !row.neverExtendBg(
+ color_palette,
+ self.background_color orelse self.default_background_color,
+ );
+ },
+ }
+
+ // 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 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;
+
+ const row_cells_all = row.cells(.all);
+
+ // If our viewport is wider than our cell contents buffer,
+ // we still only process cells up to the width of the buffer.
+ const row_cells = row_cells_all[0..@min(row_cells_all.len, self.cells.size.columns)];
+
+ for (row_cells, 0..) |*cell, x| {
+ // If this cell falls within our preedit range then we
+ // skip this because preedits are setup separately.
+ if (preedit_range) |range| preedit: {
+ // We're not on the preedit line, no actions necessary.
+ if (range.y != y) break :preedit;
+ // We're before the preedit range, no actions necessary.
+ if (x < range.x[0]) break :preedit;
+ // We're in the preedit range, skip this cell.
+ if (x <= range.x[1]) continue;
+ // After exiting the preedit range we need to catch
+ // the run position up because of the missed cells.
+ // In all other cases, no action is necessary.
+ if (x != range.x[1] + 1) break :preedit;
+
+ // Step the run iterator until we find a run that ends
+ // after the current cell, which will be the soonest run
+ // that might contain glyphs for our cell.
+ while (shaper_run) |run| {
+ if (run.offset + run.cells > x) break;
+ shaper_run = try run_iter.next(self.alloc);
+ shaper_cells = null;
+ shaper_cells_i = 0;
+ }
+
+ const run = shaper_run orelse break :preedit;
+
+ // If we haven't shaped this run, do so now.
+ shaper_cells = shaper_cells orelse
+ // Try to read the cells from the shaping cache if we can.
+ self.font_shaper_cache.get(run) orelse
+ cache: {
+ // Otherwise we have to shape them.
+ const cells = try self.font_shaper.shape(run);
+
+ // Try to cache them. If caching fails for any reason we
+ // continue because it is just a performance optimization,
+ // not a correctness issue.
+ self.font_shaper_cache.put(
+ self.alloc,
+ run,
+ cells,
+ ) catch |err| {
+ log.warn(
+ "error caching font shaping results err={}",
+ .{err},
+ );
+ };
+
+ // The cells we get from direct shaping are always owned
+ // by the shaper and valid until the next shaping call so
+ // we can safely use them.
+ break :cache cells;
+ };
+
+ // Advance our index until we reach or pass
+ // our current x position in the shaper cells.
+ while (shaper_cells.?[shaper_cells_i].x < x) {
+ shaper_cells_i += 1;
+ }
+ }
+
+ const wide = cell.wide;
+
+ const style = row.style(cell);
+
+ const cell_pin: terminal.Pin = cell: {
+ var copy = row;
+ copy.x = @intCast(x);
+ break :cell copy;
+ };
+
+ // True if this cell is selected
+ const selected: bool = if (screen.selection) |sel|
+ sel.contains(screen, .{
+ .node = row.node,
+ .y = row.y,
+ .x = @intCast(
+ // Spacer tails should show the selection
+ // state of the wide cell they belong to.
+ if (wide == .spacer_tail)
+ x -| 1
+ else
+ x,
+ ),
+ })
+ else
+ false;
+
+ const bg_style = style.bg(cell, color_palette);
+ const fg_style = style.fg(color_palette, self.config.bold_is_bright) orelse self.foreground_color orelse self.default_foreground_color;
+
+ // The final background color for the cell.
+ const bg = bg: {
+ if (selected) {
+ break :bg if (self.config.invert_selection_fg_bg)
+ if (style.flags.inverse)
+ // Cell is selected with invert selection fg/bg
+ // enabled, and the cell has the inverse style
+ // flag, so they cancel out and we get the normal
+ // bg color.
+ bg_style
+ else
+ // If it doesn't have the inverse style
+ // flag then we use the fg color instead.
+ fg_style
+ else
+ // If we don't have invert selection fg/bg set then we
+ // just use the selection background if set, otherwise
+ // the default fg color.
+ break :bg self.config.selection_background orelse self.foreground_color orelse self.default_foreground_color;
+ }
+
+ // Not selected
+ break :bg if (style.flags.inverse != isCovering(cell.codepoint()))
+ // Two cases cause us to invert (use the fg color as the bg)
+ // - The "inverse" style flag.
+ // - A "covering" glyph; we use fg for bg in that
+ // case to help make sure that padding extension
+ // works correctly.
+ //
+ // If one of these is true (but not the other)
+ // then we use the fg style color for the bg.
+ fg_style
+ else
+ // Otherwise they cancel out.
+ bg_style;
+ };
+
+ const fg = fg: {
+ if (selected and !self.config.invert_selection_fg_bg) {
+ // If we don't have invert selection fg/bg set
+ // then we just use the selection foreground if
+ // set, otherwise the default bg color.
+ break :fg self.config.selection_foreground orelse self.background_color orelse self.default_background_color;
+ }
+
+ // Whether we need to use the bg color as our fg color:
+ // - Cell is inverted and not selected
+ // - Cell is selected and not inverted
+ // Note: if selected then invert sel fg / bg must be
+ // false since we separately handle it if true above.
+ break :fg if (style.flags.inverse != selected)
+ bg_style orelse self.background_color orelse self.default_background_color
+ else
+ fg_style;
+ };
+
+ // Foreground alpha for this cell.
+ const alpha: u8 = if (style.flags.faint) 175 else 255;
+
+ // Set the cell's background color.
+ {
+ const rgb = bg orelse self.background_color orelse self.default_background_color;
+
+ // Determine our background alpha. If we have transparency configured
+ // then this is dynamic depending on some situations. This is all
+ // in an attempt to make transparency look the best for various
+ // situations. See inline comments.
+ const bg_alpha: u8 = bg_alpha: {
+ const default: u8 = 255;
+
+ // Cells that are selected should be fully opaque.
+ if (selected) break :bg_alpha default;
+
+ // Cells that are reversed should be fully opaque.
+ 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;
+
+ // 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).* = .{
+ rgb.r, rgb.g, rgb.b, bg_alpha,
+ };
+ }
+
+ // If the invisible flag is set on this cell then we
+ // don't need to render any foreground elements, so
+ // we just skip all glyphs with this x coordinate.
+ //
+ // NOTE: This behavior matches xterm. Some other terminal
+ // emulators, e.g. Alacritty, still render text decorations
+ // and only make the text itself invisible. The decision
+ // has been made here to match xterm's behavior for this.
+ if (style.flags.invisible) {
+ continue;
+ }
+
+ // Give links a single underline, unless they already have
+ // an underline, in which case use a double underline to
+ // distinguish them.
+ const underline: terminal.Attribute.Underline = if (link_match_set.contains(screen, cell_pin))
+ if (style.flags.underline == .single)
+ .double
+ else
+ .single
+ else
+ style.flags.underline;
+
+ // We draw underlines first so that they layer underneath text.
+ // This improves readability when a colored underline is used
+ // which intersects parts of the text (descenders).
+ if (underline != .none) self.addUnderline(
+ @intCast(x),
+ @intCast(y),
+ underline,
+ style.underlineColor(color_palette) orelse fg,
+ alpha,
+ ) catch |err| {
+ log.warn(
+ "error adding underline to cell, will be invalid x={} y={}, err={}",
+ .{ x, y, err },
+ );
+ };
+
+ if (style.flags.overline) self.addOverline(@intCast(x), @intCast(y), fg, alpha) catch |err| {
+ log.warn(
+ "error adding overline to cell, will be invalid x={} y={}, err={}",
+ .{ x, y, err },
+ );
+ };
+
+ // If we're at or past the end of our shaper run then
+ // we need to get the next run from the run iterator.
+ if (shaper_cells != null and shaper_cells_i >= shaper_cells.?.len) {
+ shaper_run = try run_iter.next(self.alloc);
+ shaper_cells = null;
+ shaper_cells_i = 0;
+ }
+
+ if (shaper_run) |run| glyphs: {
+ // If we haven't shaped this run yet, do so.
+ shaper_cells = shaper_cells orelse
+ // Try to read the cells from the shaping cache if we can.
+ self.font_shaper_cache.get(run) orelse
+ cache: {
+ // Otherwise we have to shape them.
+ const cells = try self.font_shaper.shape(run);
+
+ // Try to cache them. If caching fails for any reason we
+ // continue because it is just a performance optimization,
+ // not a correctness issue.
+ self.font_shaper_cache.put(
+ self.alloc,
+ run,
+ cells,
+ ) catch |err| {
+ log.warn(
+ "error caching font shaping results err={}",
+ .{err},
+ );
+ };
+
+ // The cells we get from direct shaping are always owned
+ // by the shaper and valid until the next shaping call so
+ // we can safely use them.
+ break :cache cells;
+ };
+
+ const cells = shaper_cells orelse break :glyphs;
+
+ // If there are no shaper cells for this run, ignore it.
+ // This can occur for runs of empty cells, and is fine.
+ if (cells.len == 0) break :glyphs;
+
+ // If we encounter a shaper cell to the left of the current
+ // cell then we have some problems. This logic relies on x
+ // position monotonically increasing.
+ assert(cells[shaper_cells_i].x >= x);
+
+ // NOTE: An assumption is made here that a single cell will never
+ // be present in more than one shaper run. If that assumption is
+ // violated, this logic breaks.
+
+ while (shaper_cells_i < cells.len and cells[shaper_cells_i].x == x) : ({
+ shaper_cells_i += 1;
+ }) {
+ self.addGlyph(
+ @intCast(x),
+ @intCast(y),
+ cell_pin,
+ cells[shaper_cells_i],
+ shaper_run.?,
+ fg,
+ alpha,
+ ) catch |err| {
+ log.warn(
+ "error adding glyph to cell, will be invalid x={} y={}, err={}",
+ .{ x, y, err },
+ );
+ };
+ }
+ }
+
+ // Finally, draw a strikethrough if necessary.
+ if (style.flags.strikethrough) self.addStrikethrough(
+ @intCast(x),
+ @intCast(y),
+ fg,
+ alpha,
+ ) catch |err| {
+ log.warn(
+ "error adding strikethrough to cell, will be invalid x={} y={}, err={}",
+ .{ x, y, err },
+ );
+ };
+ }
+ }
+
+ // Setup our cursor rendering information.
+ cursor: {
+ // By default, we don't handle cursor inversion on the shader.
+ self.cells.setCursor(null);
+ self.uniforms.cursor_pos = .{
+ std.math.maxInt(u16),
+ std.math.maxInt(u16),
+ };
+
+ // If we have preedit text, we don't setup a cursor
+ if (preedit != null) break :cursor;
+
+ // Prepare the cursor cell contents.
+ const style = cursor_style_ orelse break :cursor;
+ const cursor_color = self.cursor_color orelse self.default_cursor_color orelse color: {
+ if (self.cursor_invert) {
+ // Use the foreground color from the cell under the cursor, if any.
+ const sty = screen.cursor.page_pin.style(screen.cursor.page_cell);
+ break :color if (sty.flags.inverse)
+ // If the cell is reversed, use background color instead.
+ (sty.bg(screen.cursor.page_cell, color_palette) orelse self.background_color orelse self.default_background_color)
+ else
+ (sty.fg(color_palette, self.config.bold_is_bright) orelse self.foreground_color orelse self.default_foreground_color);
+ } else {
+ break :color self.foreground_color orelse self.default_foreground_color;
+ }
+ };
+
+ self.addCursor(screen, style, cursor_color);
+
+ // If the cursor is visible then we set our uniforms.
+ if (style == .block and screen.viewportIsBottom()) {
+ const wide = screen.cursor.page_cell.wide;
+
+ self.uniforms.cursor_pos = .{
+ // If we are a spacer tail of a wide cell, our cursor needs
+ // to move back one cell. The saturate is to ensure we don't
+ // overflow but this shouldn't happen with well-formed input.
+ switch (wide) {
+ .narrow, .spacer_head, .wide => screen.cursor.x,
+ .spacer_tail => screen.cursor.x -| 1,
+ },
+ screen.cursor.y,
+ };
+
+ self.uniforms.bools.cursor_wide = switch (wide) {
+ .narrow, .spacer_head => false,
+ .wide, .spacer_tail => true,
+ };
+
+ const uniform_color = if (self.cursor_invert) blk: {
+ // Use the background color from the cell under the cursor, if any.
+ const sty = screen.cursor.page_pin.style(screen.cursor.page_cell);
+ break :blk if (sty.flags.inverse)
+ // If the cell is reversed, use foreground color instead.
+ (sty.fg(color_palette, self.config.bold_is_bright) orelse self.foreground_color orelse self.default_foreground_color)
+ else
+ (sty.bg(screen.cursor.page_cell, color_palette) orelse self.background_color orelse self.default_background_color);
+ } else if (self.config.cursor_text) |txt|
+ txt
+ else
+ self.background_color orelse self.default_background_color;
+
+ self.uniforms.cursor_color = .{
+ uniform_color.r,
+ uniform_color.g,
+ uniform_color.b,
+ 255,
+ };
+ }
+ }
+
+ // Setup our preedit text.
+ if (preedit) |preedit_v| {
+ const range = preedit_range.?;
+ var x = range.x[0];
+ for (preedit_v.codepoints[range.cp_offset..]) |cp| {
+ self.addPreeditCell(cp, .{ .x = x, .y = range.y }) catch |err| {
+ log.warn("error building preedit cell, will be invalid x={} y={}, err={}", .{
+ x,
+ range.y,
+ err,
+ });
+ };
+
+ x += if (cp.wide) 2 else 1;
+ }
+ }
+
+ // Update that our cells rebuilt
+ self.cells_rebuilt = true;
+
+ // Log some things
+ // log.debug("rebuildCells complete cached_runs={}", .{
+ // self.font_shaper_cache.count(),
+ // });
+ }
+
+ /// Add an underline decoration to the specified cell
+ fn addUnderline(
+ self: *Self,
+ x: terminal.size.CellCountInt,
+ y: terminal.size.CellCountInt,
+ style: terminal.Attribute.Underline,
+ color: terminal.color.RGB,
+ alpha: u8,
+ ) !void {
+ const sprite: font.Sprite = switch (style) {
+ .none => unreachable,
+ .single => .underline,
+ .double => .underline_double,
+ .dotted => .underline_dotted,
+ .dashed => .underline_dashed,
+ .curly => .underline_curly,
+ };
+
+ const render = try self.font_grid.renderGlyph(
+ self.alloc,
+ font.sprite_index,
+ @intFromEnum(sprite),
+ .{
+ .cell_width = 1,
+ .grid_metrics = self.grid_metrics,
+ },
+ );
+
+ try self.cells.add(self.alloc, .underline, .{
+ .mode = .fg,
+ .grid_pos = .{ @intCast(x), @intCast(y) },
+ .constraint_width = 1,
+ .color = .{ color.r, color.g, color.b, alpha },
+ .glyph_pos = .{ render.glyph.atlas_x, render.glyph.atlas_y },
+ .glyph_size = .{ render.glyph.width, render.glyph.height },
+ .bearings = .{
+ @intCast(render.glyph.offset_x),
+ @intCast(render.glyph.offset_y),
+ },
+ });
+ }
+
+ /// Add a overline decoration to the specified cell
+ fn addOverline(
+ self: *Self,
+ x: terminal.size.CellCountInt,
+ y: terminal.size.CellCountInt,
+ color: terminal.color.RGB,
+ alpha: u8,
+ ) !void {
+ const render = try self.font_grid.renderGlyph(
+ self.alloc,
+ font.sprite_index,
+ @intFromEnum(font.Sprite.overline),
+ .{
+ .cell_width = 1,
+ .grid_metrics = self.grid_metrics,
+ },
+ );
+
+ try self.cells.add(self.alloc, .overline, .{
+ .mode = .fg,
+ .grid_pos = .{ @intCast(x), @intCast(y) },
+ .constraint_width = 1,
+ .color = .{ color.r, color.g, color.b, alpha },
+ .glyph_pos = .{ render.glyph.atlas_x, render.glyph.atlas_y },
+ .glyph_size = .{ render.glyph.width, render.glyph.height },
+ .bearings = .{
+ @intCast(render.glyph.offset_x),
+ @intCast(render.glyph.offset_y),
+ },
+ });
+ }
+
+ /// Add a strikethrough decoration to the specified cell
+ fn addStrikethrough(
+ self: *Self,
+ x: terminal.size.CellCountInt,
+ y: terminal.size.CellCountInt,
+ color: terminal.color.RGB,
+ alpha: u8,
+ ) !void {
+ const render = try self.font_grid.renderGlyph(
+ self.alloc,
+ font.sprite_index,
+ @intFromEnum(font.Sprite.strikethrough),
+ .{
+ .cell_width = 1,
+ .grid_metrics = self.grid_metrics,
+ },
+ );
+
+ try self.cells.add(self.alloc, .strikethrough, .{
+ .mode = .fg,
+ .grid_pos = .{ @intCast(x), @intCast(y) },
+ .constraint_width = 1,
+ .color = .{ color.r, color.g, color.b, alpha },
+ .glyph_pos = .{ render.glyph.atlas_x, render.glyph.atlas_y },
+ .glyph_size = .{ render.glyph.width, render.glyph.height },
+ .bearings = .{
+ @intCast(render.glyph.offset_x),
+ @intCast(render.glyph.offset_y),
+ },
+ });
+ }
+
+ // Add a glyph to the specified cell.
+ fn addGlyph(
+ self: *Self,
+ x: terminal.size.CellCountInt,
+ y: terminal.size.CellCountInt,
+ cell_pin: terminal.Pin,
+ shaper_cell: font.shape.Cell,
+ shaper_run: font.shape.TextRun,
+ color: terminal.color.RGB,
+ alpha: u8,
+ ) !void {
+ const rac = cell_pin.rowAndCell();
+ const cell = rac.cell;
+
+ // Render
+ const render = try self.font_grid.renderGlyph(
+ self.alloc,
+ shaper_run.font_index,
+ shaper_cell.glyph_index,
+ .{
+ .grid_metrics = self.grid_metrics,
+ .thicken = self.config.font_thicken,
+ .thicken_strength = self.config.font_thicken_strength,
+ },
+ );
+
+ // If the glyph is 0 width or height, it will be invisible
+ // when drawn, so don't bother adding it to the buffer.
+ if (render.glyph.width == 0 or render.glyph.height == 0) {
+ return;
+ }
+
+ const mode: shaderpkg.CellText.Mode = switch (fgMode(
+ render.presentation,
+ cell_pin,
+ )) {
+ .normal => .fg,
+ .color => .fg_color,
+ .constrained => .fg_constrained,
+ .powerline => .fg_powerline,
+ };
+
+ try self.cells.add(self.alloc, .text, .{
+ .mode = mode,
+ .grid_pos = .{ @intCast(x), @intCast(y) },
+ .constraint_width = cell.gridWidth(),
+ .color = .{ color.r, color.g, color.b, alpha },
+ .glyph_pos = .{ render.glyph.atlas_x, render.glyph.atlas_y },
+ .glyph_size = .{ render.glyph.width, render.glyph.height },
+ .bearings = .{
+ @intCast(render.glyph.offset_x + shaper_cell.x_offset),
+ @intCast(render.glyph.offset_y + shaper_cell.y_offset),
+ },
+ });
+ }
+
+ fn addCursor(
+ self: *Self,
+ screen: *terminal.Screen,
+ cursor_style: renderer.CursorStyle,
+ cursor_color: terminal.color.RGB,
+ ) void {
+ // Add the cursor. We render the cursor over the wide character if
+ // we're on the wide character tail.
+ const wide, const x = cell: {
+ // The cursor goes over the screen cursor position.
+ const cell = screen.cursor.page_cell;
+ if (cell.wide != .spacer_tail or screen.cursor.x == 0)
+ break :cell .{ cell.wide == .wide, screen.cursor.x };
+
+ // If we're part of a wide character, we move the cursor back to
+ // the actual character.
+ const prev_cell = screen.cursorCellLeft(1);
+ break :cell .{ prev_cell.wide == .wide, screen.cursor.x - 1 };
+ };
+
+ const alpha: u8 = if (!self.focused) 255 else alpha: {
+ const alpha = 255 * self.config.cursor_opacity;
+ break :alpha @intFromFloat(@ceil(alpha));
+ };
+
+ const render = switch (cursor_style) {
+ .block,
+ .block_hollow,
+ .bar,
+ .underline,
+ => render: {
+ const sprite: font.Sprite = switch (cursor_style) {
+ .block => .cursor_rect,
+ .block_hollow => .cursor_hollow_rect,
+ .bar => .cursor_bar,
+ .underline => .underline,
+ .lock => unreachable,
+ };
+
+ break :render self.font_grid.renderGlyph(
+ self.alloc,
+ font.sprite_index,
+ @intFromEnum(sprite),
+ .{
+ .cell_width = if (wide) 2 else 1,
+ .grid_metrics = self.grid_metrics,
+ },
+ ) catch |err| {
+ log.warn("error rendering cursor glyph err={}", .{err});
+ return;
+ };
+ },
+
+ .lock => self.font_grid.renderCodepoint(
+ self.alloc,
+ 0xF023, // lock symbol
+ .regular,
+ .text,
+ .{
+ .cell_width = if (wide) 2 else 1,
+ .grid_metrics = self.grid_metrics,
+ },
+ ) catch |err| {
+ log.warn("error rendering cursor glyph err={}", .{err});
+ return;
+ } orelse {
+ // This should never happen because we embed nerd
+ // fonts so we just log and return instead of fallback.
+ log.warn("failed to find lock symbol for cursor codepoint=0xF023", .{});
+ return;
+ },
+ };
+
+ self.cells.setCursor(.{
+ .mode = .cursor,
+ .grid_pos = .{ x, screen.cursor.y },
+ .color = .{ cursor_color.r, cursor_color.g, cursor_color.b, alpha },
+ .glyph_pos = .{ render.glyph.atlas_x, render.glyph.atlas_y },
+ .glyph_size = .{ render.glyph.width, render.glyph.height },
+ .bearings = .{
+ @intCast(render.glyph.offset_x),
+ @intCast(render.glyph.offset_y),
+ },
+ });
+ }
+
+ fn addPreeditCell(
+ self: *Self,
+ cp: renderer.State.Preedit.Codepoint,
+ coord: terminal.Coordinate,
+ ) !void {
+ // Preedit is rendered inverted
+ const bg = self.foreground_color orelse self.default_foreground_color;
+ const fg = self.background_color orelse self.default_background_color;
+
+ // Render the glyph for our preedit text
+ const render_ = self.font_grid.renderCodepoint(
+ self.alloc,
+ @intCast(cp.codepoint),
+ .regular,
+ .text,
+ .{ .grid_metrics = self.grid_metrics },
+ ) catch |err| {
+ log.warn("error rendering preedit glyph err={}", .{err});
+ return;
+ };
+ const render = render_ orelse {
+ log.warn("failed to find font for preedit codepoint={X}", .{cp.codepoint});
+ return;
+ };
+
+ // Add our opaque background cell
+ self.cells.bgCell(coord.y, coord.x).* = .{
+ bg.r, bg.g, bg.b, 255,
+ };
+ if (cp.wide and coord.x < self.cells.size.columns - 1) {
+ self.cells.bgCell(coord.y, coord.x + 1).* = .{
+ bg.r, bg.g, bg.b, 255,
+ };
+ }
+
+ // Add our text
+ try self.cells.add(self.alloc, .text, .{
+ .mode = .fg,
+ .grid_pos = .{ @intCast(coord.x), @intCast(coord.y) },
+ .color = .{ fg.r, fg.g, fg.b, 255 },
+ .glyph_pos = .{ render.glyph.atlas_x, render.glyph.atlas_y },
+ .glyph_size = .{ render.glyph.width, render.glyph.height },
+ .bearings = .{
+ @intCast(render.glyph.offset_x),
+ @intCast(render.glyph.offset_y),
+ },
+ });
+ }
+
+ /// Sync the atlas data to the given texture. This copies the bytes
+ /// associated with the atlas to the given texture. If the atlas no
+ /// longer fits into the texture, the texture will be resized.
+ fn syncAtlasTexture(
+ self: *const Self,
+ atlas: *const font.Atlas,
+ texture: *Texture,
+ ) !void {
+ if (atlas.size > texture.width) {
+ // Free our old texture
+ texture.*.deinit();
+
+ // Reallocate
+ texture.* = try self.api.initAtlasTexture(atlas);
+ }
+
+ try texture.replaceRegion(0, 0, atlas.size, atlas.size, atlas.data);
+ }
+ };
+}
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/link.zig b/src/renderer/link.zig
index 994190ec8..410fb8632 100644
--- a/src/renderer/link.zig
+++ b/src/renderer/link.zig
@@ -179,7 +179,7 @@ pub const Set = struct {
if (current) |*sel| {
sel.endPtr().* = cell_pin;
} else {
- current = terminal.Selection.init(
+ current = .init(
cell_pin,
cell_pin,
false,
diff --git a/src/renderer/metal/Frame.zig b/src/renderer/metal/Frame.zig
new file mode 100644
index 000000000..81b38e7b6
--- /dev/null
+++ b/src/renderer/metal/Frame.zig
@@ -0,0 +1,137 @@
+//! Wrapper for handling render passes.
+const Self = @This();
+
+const std = @import("std");
+const Allocator = std.mem.Allocator;
+const assert = std.debug.assert;
+const builtin = @import("builtin");
+const objc = @import("objc");
+
+const mtl = @import("api.zig");
+const Renderer = @import("../generic.zig").Renderer(Metal);
+const Metal = @import("../Metal.zig");
+const Target = @import("Target.zig");
+const Pipeline = @import("Pipeline.zig");
+const RenderPass = @import("RenderPass.zig");
+const Buffer = @import("buffer.zig").Buffer;
+
+const Health = @import("../../renderer.zig").Health;
+
+const log = std.log.scoped(.metal);
+
+/// Options for beginning a frame.
+pub const Options = struct {
+ /// MTLCommandQueue
+ queue: objc.Object,
+};
+
+/// MTLCommandBuffer
+buffer: objc.Object,
+
+block: CompletionBlock,
+
+/// Begin encoding a frame.
+pub fn begin(
+ opts: Options,
+ /// Once the frame has been completed, the `frameCompleted` method
+ /// on the renderer is called with the health status of the frame.
+ renderer: *Renderer,
+ /// The target is presented via the provided renderer's API when completed.
+ target: *Target,
+) !Self {
+ const buffer = opts.queue.msgSend(
+ objc.Object,
+ objc.sel("commandBuffer"),
+ .{},
+ );
+
+ // Create our block to register for completion updates.
+ // The block is deallocated by the objC runtime on success.
+ const block = try CompletionBlock.init(
+ .{
+ .renderer = renderer,
+ .target = target,
+ .sync = false,
+ },
+ &bufferCompleted,
+ );
+ errdefer block.deinit();
+
+ return .{ .buffer = buffer, .block = block };
+}
+
+/// This is the block type used for the addCompletedHandler callback.
+const CompletionBlock = objc.Block(struct {
+ renderer: *Renderer,
+ target: *Target,
+ sync: bool,
+}, .{
+ objc.c.id, // MTLCommandBuffer
+}, void);
+
+fn bufferCompleted(
+ block: *const CompletionBlock.Context,
+ buffer_id: objc.c.id,
+) callconv(.c) void {
+ const buffer = objc.Object.fromId(buffer_id);
+
+ // Get our command buffer status to pass back to the generic renderer.
+ const status = buffer.getProperty(mtl.MTLCommandBufferStatus, "status");
+ const health: Health = switch (status) {
+ .@"error" => .unhealthy,
+ else => .healthy,
+ };
+
+ // If the frame is healthy, present it.
+ if (health == .healthy) {
+ block.renderer.api.present(
+ block.target.*,
+ block.sync,
+ ) catch |err| {
+ log.err("Failed to present render target: err={}", .{err});
+ };
+ }
+
+ block.renderer.frameCompleted(health);
+}
+
+/// Add a render pass to this frame with the provided attachments.
+/// Returns a RenderPass which allows render steps to be added.
+pub inline fn renderPass(
+ self: *const Self,
+ attachments: []const RenderPass.Options.Attachment,
+) RenderPass {
+ return RenderPass.begin(.{
+ .attachments = attachments,
+ .command_buffer = self.buffer,
+ });
+}
+
+/// Complete this frame and present the target.
+///
+/// If `sync` is true, this will block until the frame is presented.
+pub inline fn complete(self: *Self, sync: bool) void {
+ // If we don't need to complete synchronously,
+ // we add our block as a completion handler.
+ //
+ // It will be deallocated by the objc runtime on success.
+ if (!sync) {
+ self.buffer.msgSend(
+ void,
+ objc.sel("addCompletedHandler:"),
+ .{self.block.context},
+ );
+ }
+
+ self.buffer.msgSend(void, objc.sel("commit"), .{});
+
+ // If we need to complete synchronously, we wait until
+ // the buffer is completed and call the callback directly,
+ // deiniting the block after we're done.
+ if (sync) {
+ self.buffer.msgSend(void, "waitUntilCompleted", .{});
+ self.block.context.sync = true;
+ bufferCompleted(self.block.context, self.buffer.value);
+ self.block.deinit();
+ }
+}
diff --git a/src/renderer/metal/IOSurfaceLayer.zig b/src/renderer/metal/IOSurfaceLayer.zig
new file mode 100644
index 000000000..9212bd5e1
--- /dev/null
+++ b/src/renderer/metal/IOSurfaceLayer.zig
@@ -0,0 +1,190 @@
+//! A wrapper around a CALayer with a utility method
+//! for settings its `contents` to an IOSurface.
+const IOSurfaceLayer = @This();
+
+const std = @import("std");
+const Allocator = std.mem.Allocator;
+const assert = std.debug.assert;
+const builtin = @import("builtin");
+const objc = @import("objc");
+const macos = @import("macos");
+
+const IOSurface = macos.iosurface.IOSurface;
+
+const log = std.log.scoped(.IOSurfaceLayer);
+
+/// We subclass CALayer with a custom display handler, we only need
+/// to make the subclass once, and then we can use it as a singleton.
+var Subclass: ?objc.Class = null;
+
+/// The underlying CALayer
+layer: objc.Object,
+
+pub fn init() !IOSurfaceLayer {
+ // The layer returned by `[CALayer layer]` is autoreleased, which means
+ // that at the end of the current autorelease pool it will be deallocated
+ // if it isn't retained, so we retain it here manually an extra time.
+ const layer = (try getSubclass()).msgSend(
+ objc.Object,
+ objc.sel("layer"),
+ .{},
+ ).retain();
+ errdefer layer.release();
+
+ // The layer gravity is set to top-left so that the contents aren't
+ // stretched during resize operations before a new frame has been drawn.
+ layer.setProperty("contentsGravity", macos.animation.kCAGravityTopLeft);
+
+ layer.setInstanceVariable("display_cb", .{ .value = null });
+ layer.setInstanceVariable("display_ctx", .{ .value = null });
+
+ return .{ .layer = layer };
+}
+
+pub fn release(self: *IOSurfaceLayer) void {
+ self.layer.release();
+}
+
+/// Sets the layer's `contents` to the provided IOSurface.
+///
+/// Makes sure to do so on the main thread to avoid visual artifacts.
+pub inline fn setSurface(self: *IOSurfaceLayer, surface: *IOSurface) !void {
+ // We retain the surface to make sure it's not GC'd
+ // before we can set it as the contents of the layer.
+ //
+ // We release in the callback after setting the contents.
+ surface.retain();
+ // We also need to retain the layer itself to make sure it
+ // isn't destroyed before the callback completes, since if
+ // that happens it will try to interact with a deallocated
+ // object.
+ _ = self.layer.retain();
+
+ var block = try SetSurfaceBlock.init(.{
+ .layer = self.layer.value,
+ .surface = surface,
+ }, &setSurfaceCallback);
+
+ // We check if we're on the main thread and run the block directly if so.
+ const NSThread = objc.getClass("NSThread").?;
+ if (NSThread.msgSend(bool, "isMainThread", .{})) {
+ setSurfaceCallback(block.context);
+ block.deinit();
+ } else {
+ // NOTE: The block will automatically be deallocated by the objc
+ // runtime once it's executed, so there's no need to deinit it.
+
+ macos.dispatch.dispatch_async(
+ @ptrCast(macos.dispatch.queue.getMain()),
+ @ptrCast(block.context),
+ );
+ }
+}
+
+/// Sets the layer's `contents` to the provided IOSurface.
+///
+/// Does not ensure this happens on the main thread.
+pub inline fn setSurfaceSync(self: *IOSurfaceLayer, surface: *IOSurface) void {
+ self.layer.setProperty("contents", surface);
+}
+
+const SetSurfaceBlock = objc.Block(struct {
+ layer: objc.c.id,
+ surface: *IOSurface,
+}, .{}, void);
+
+fn setSurfaceCallback(
+ block: *const SetSurfaceBlock.Context,
+) callconv(.c) void {
+ const layer = objc.Object.fromId(block.layer);
+ const surface: *IOSurface = block.surface;
+
+ // See explanation of why we retain and release in `setSurface`.
+ defer {
+ surface.release();
+ layer.release();
+ }
+
+ // We check to see if the surface is the appropriate size for
+ // the layer, if it's not then we discard it. This is because
+ // asynchronously drawn frames can sometimes finish just after
+ // a synchronously drawn frame during a resize, and if we don't
+ // discard the improperly sized surface it creates jank.
+ const bounds = layer.getProperty(macos.graphics.Rect, "bounds");
+ const scale = layer.getProperty(f64, "contentsScale");
+ const width: usize = @intFromFloat(bounds.size.width * scale);
+ const height: usize = @intFromFloat(bounds.size.height * scale);
+ if (width != surface.getWidth() or height != surface.getHeight()) {
+ log.debug(
+ "setSurfaceCallback(): surface is wrong size for layer, discarding. surface = {d}x{d}, layer = {d}x{d}",
+ .{ surface.getWidth(), surface.getHeight(), width, height },
+ );
+ return;
+ }
+
+ layer.setProperty("contents", surface);
+}
+
+pub const DisplayCallback = ?*align(8) const fn (?*anyopaque) void;
+
+pub fn setDisplayCallback(
+ self: *IOSurfaceLayer,
+ display_cb: DisplayCallback,
+ display_ctx: ?*anyopaque,
+) void {
+ self.layer.setInstanceVariable(
+ "display_cb",
+ objc.Object.fromId(@constCast(display_cb)),
+ );
+ self.layer.setInstanceVariable(
+ "display_ctx",
+ objc.Object.fromId(display_ctx),
+ );
+}
+
+fn getSubclass() error{ObjCFailed}!objc.Class {
+ if (Subclass) |c| return c;
+
+ const CALayer =
+ objc.getClass("CALayer") orelse return error.ObjCFailed;
+
+ var subclass =
+ objc.allocateClassPair(CALayer, "IOSurfaceLayer") orelse return error.ObjCFailed;
+ errdefer objc.disposeClassPair(subclass);
+
+ if (!subclass.addIvar("display_cb")) return error.ObjCFailed;
+ if (!subclass.addIvar("display_ctx")) return error.ObjCFailed;
+
+ subclass.replaceMethod("display", struct {
+ fn display(target: objc.c.id, sel: objc.c.SEL) callconv(.c) void {
+ _ = sel;
+ const self = objc.Object.fromId(target);
+ const display_cb: DisplayCallback = @ptrFromInt(@intFromPtr(
+ self.getInstanceVariable("display_cb").value,
+ ));
+ if (display_cb) |cb| cb(
+ @ptrCast(self.getInstanceVariable("display_ctx").value),
+ );
+ }
+ }.display);
+
+ // Disable all animations for this layer by returning null for all actions.
+ subclass.replaceMethod("actionForKey:", struct {
+ fn actionForKey(
+ target: objc.c.id,
+ sel: objc.c.SEL,
+ key: objc.c.id,
+ ) callconv(.c) objc.c.id {
+ _ = target;
+ _ = sel;
+ _ = key;
+ return objc.getClass("NSNull").?.msgSend(objc.c.id, "null", .{});
+ }
+ }.actionForKey);
+
+ objc.registerClassPair(subclass);
+
+ Subclass = subclass;
+
+ return subclass;
+}
diff --git a/src/renderer/metal/Pipeline.zig b/src/renderer/metal/Pipeline.zig
new file mode 100644
index 000000000..0b8e99159
--- /dev/null
+++ b/src/renderer/metal/Pipeline.zig
@@ -0,0 +1,208 @@
+//! Wrapper for handling render pipelines.
+const Self = @This();
+
+const std = @import("std");
+const Allocator = std.mem.Allocator;
+const assert = std.debug.assert;
+const builtin = @import("builtin");
+const macos = @import("macos");
+const objc = @import("objc");
+
+const mtl = @import("api.zig");
+const Texture = @import("Texture.zig");
+const Metal = @import("../Metal.zig");
+
+const log = std.log.scoped(.metal);
+
+/// Options for initializing a render pipeline.
+pub const Options = struct {
+ /// MTLDevice
+ device: objc.Object,
+
+ /// Name of the vertex function
+ vertex_fn: []const u8,
+ /// Name of the fragment function
+ fragment_fn: []const u8,
+
+ /// MTLLibrary to get the vertex function from
+ vertex_library: objc.Object,
+ /// MTLLibrary to get the fragment function from
+ fragment_library: objc.Object,
+
+ /// Vertex step function
+ step_fn: mtl.MTLVertexStepFunction = .per_vertex,
+
+ /// Info about the color attachments used by this render pipeline.
+ attachments: []const Attachment,
+
+ /// Describes a color attachment.
+ pub const Attachment = struct {
+ pixel_format: mtl.MTLPixelFormat,
+ blending_enabled: bool = true,
+ };
+};
+
+/// MTLRenderPipelineState
+state: objc.Object,
+
+pub fn init(comptime VertexAttributes: ?type, opts: Options) !Self {
+ // Create our descriptor
+ const desc = init: {
+ const Class = objc.getClass("MTLRenderPipelineDescriptor").?;
+ const id_alloc = Class.msgSend(objc.Object, objc.sel("alloc"), .{});
+ const id_init = id_alloc.msgSend(objc.Object, objc.sel("init"), .{});
+ break :init id_init;
+ };
+ defer desc.msgSend(void, objc.sel("release"), .{});
+
+ // Get our vertex and fragment functions and add them to the descriptor.
+ {
+ const str = try macos.foundation.String.createWithBytes(
+ opts.vertex_fn,
+ .utf8,
+ false,
+ );
+ defer str.release();
+
+ const ptr = opts.vertex_library.msgSend(?*anyopaque, objc.sel("newFunctionWithName:"), .{str});
+ const func_vert = objc.Object.fromId(ptr.?);
+ defer func_vert.msgSend(void, objc.sel("release"), .{});
+
+ desc.setProperty("vertexFunction", func_vert);
+ }
+ {
+ const str = try macos.foundation.String.createWithBytes(
+ opts.fragment_fn,
+ .utf8,
+ false,
+ );
+ defer str.release();
+
+ const ptr = opts.fragment_library.msgSend(?*anyopaque, objc.sel("newFunctionWithName:"), .{str});
+ const func_frag = objc.Object.fromId(ptr.?);
+ defer func_frag.msgSend(void, objc.sel("release"), .{});
+
+ desc.setProperty("fragmentFunction", func_frag);
+ }
+
+ // If we have vertex attributes, create and add a vertex descriptor.
+ if (VertexAttributes) |V| {
+ const vertex_desc = init: {
+ const Class = objc.getClass("MTLVertexDescriptor").?;
+ const id_alloc = Class.msgSend(objc.Object, objc.sel("alloc"), .{});
+ const id_init = id_alloc.msgSend(objc.Object, objc.sel("init"), .{});
+ break :init id_init;
+ };
+ defer vertex_desc.msgSend(void, objc.sel("release"), .{});
+
+ // Our attributes are the fields of the input
+ const attrs = objc.Object.fromId(vertex_desc.getProperty(?*anyopaque, "attributes"));
+ autoAttribute(V, attrs);
+
+ // The layout describes how and when we fetch the next vertex input.
+ const layouts = objc.Object.fromId(vertex_desc.getProperty(?*anyopaque, "layouts"));
+ {
+ const layout = layouts.msgSend(
+ objc.Object,
+ objc.sel("objectAtIndexedSubscript:"),
+ .{@as(c_ulong, 0)},
+ );
+
+ layout.setProperty("stepFunction", @intFromEnum(opts.step_fn));
+ layout.setProperty("stride", @as(c_ulong, @sizeOf(V)));
+ }
+
+ desc.setProperty("vertexDescriptor", vertex_desc);
+ }
+
+ // Set our color attachment
+ const attachments = objc.Object.fromId(desc.getProperty(?*anyopaque, "colorAttachments"));
+ for (opts.attachments, 0..) |at, i| {
+ const attachment = attachments.msgSend(
+ objc.Object,
+ objc.sel("objectAtIndexedSubscript:"),
+ .{@as(c_ulong, i)},
+ );
+
+ attachment.setProperty("pixelFormat", @intFromEnum(at.pixel_format));
+
+ attachment.setProperty("blendingEnabled", at.blending_enabled);
+ // We always use premultiplied alpha blending for now.
+ if (at.blending_enabled) {
+ attachment.setProperty("rgbBlendOperation", @intFromEnum(mtl.MTLBlendOperation.add));
+ attachment.setProperty("alphaBlendOperation", @intFromEnum(mtl.MTLBlendOperation.add));
+ attachment.setProperty("sourceRGBBlendFactor", @intFromEnum(mtl.MTLBlendFactor.one));
+ attachment.setProperty("sourceAlphaBlendFactor", @intFromEnum(mtl.MTLBlendFactor.one));
+ attachment.setProperty("destinationRGBBlendFactor", @intFromEnum(mtl.MTLBlendFactor.one_minus_source_alpha));
+ attachment.setProperty("destinationAlphaBlendFactor", @intFromEnum(mtl.MTLBlendFactor.one_minus_source_alpha));
+ }
+ }
+
+ // Make our state
+ var err: ?*anyopaque = null;
+ const pipeline_state = opts.device.msgSend(
+ objc.Object,
+ objc.sel("newRenderPipelineStateWithDescriptor:error:"),
+ .{ desc, &err },
+ );
+ try checkError(err);
+ errdefer pipeline_state.release();
+
+ return .{ .state = pipeline_state };
+}
+
+pub fn deinit(self: *const Self) void {
+ self.state.release();
+}
+
+fn autoAttribute(T: type, attrs: objc.Object) void {
+ inline for (@typeInfo(T).@"struct".fields, 0..) |field, i| {
+ 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,
+ };
+
+ // Very incomplete list, expand as necessary.
+ const format = switch (FT) {
+ [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,
+ };
+
+ const attr = attrs.msgSend(
+ objc.Object,
+ objc.sel("objectAtIndexedSubscript:"),
+ .{@as(c_ulong, i)},
+ );
+
+ attr.setProperty("format", @intFromEnum(format));
+ attr.setProperty("offset", @as(c_ulong, offset));
+ attr.setProperty("bufferIndex", @as(c_ulong, 0));
+ }
+}
+
+fn checkError(err_: ?*anyopaque) !void {
+ const nserr = objc.Object.fromId(err_ orelse return);
+ const str = @as(
+ *macos.foundation.String,
+ @ptrCast(nserr.getProperty(?*anyopaque, "localizedDescription").?),
+ );
+
+ log.err("metal error={s}", .{str.cstringPtr(.ascii).?});
+ return error.MetalFailed;
+}
diff --git a/src/renderer/metal/RenderPass.zig b/src/renderer/metal/RenderPass.zig
new file mode 100644
index 000000000..e48bc4c00
--- /dev/null
+++ b/src/renderer/metal/RenderPass.zig
@@ -0,0 +1,220 @@
+//! Wrapper for handling render passes.
+const Self = @This();
+
+const std = @import("std");
+const Allocator = std.mem.Allocator;
+const assert = std.debug.assert;
+const builtin = @import("builtin");
+const objc = @import("objc");
+
+const mtl = @import("api.zig");
+const Pipeline = @import("Pipeline.zig");
+const Texture = @import("Texture.zig");
+const Target = @import("Target.zig");
+const Metal = @import("../Metal.zig");
+const Buffer = @import("buffer.zig").Buffer;
+
+const log = std.log.scoped(.metal);
+
+/// Options for beginning a render pass.
+pub const Options = struct {
+ /// MTLCommandBuffer
+ command_buffer: objc.Object,
+ /// Color attachments for this render pass.
+ attachments: []const Attachment,
+
+ /// Describes a color attachment.
+ pub const Attachment = struct {
+ target: union(enum) {
+ texture: Texture,
+ target: Target,
+ },
+ clear_color: ?[4]f64 = null,
+ };
+};
+
+/// Describes a step in a render pass.
+pub const Step = struct {
+ pipeline: Pipeline,
+ /// MTLBuffer
+ uniforms: ?objc.Object = null,
+ /// MTLBuffer
+ buffers: []const ?objc.Object = &.{},
+ textures: []const ?Texture = &.{},
+ draw: Draw,
+
+ /// Describes the draw call for this step.
+ pub const Draw = struct {
+ type: mtl.MTLPrimitiveType,
+ vertex_count: usize,
+ instance_count: usize = 1,
+ };
+};
+
+/// MTLRenderCommandEncoder
+encoder: objc.Object,
+
+/// Begin a render pass.
+pub fn begin(
+ opts: Options,
+) Self {
+ // Create a pass descriptor
+ const desc = desc: {
+ const MTLRenderPassDescriptor = objc.getClass("MTLRenderPassDescriptor").?;
+ const desc = MTLRenderPassDescriptor.msgSend(
+ objc.Object,
+ objc.sel("renderPassDescriptor"),
+ .{},
+ );
+
+ // Set our color attachment to be our drawable surface.
+ const attachments = objc.Object.fromId(
+ desc.getProperty(?*anyopaque, "colorAttachments"),
+ );
+ for (opts.attachments, 0..) |at, i| {
+ const attachment = attachments.msgSend(
+ objc.Object,
+ objc.sel("objectAtIndexedSubscript:"),
+ .{@as(c_ulong, i)},
+ );
+
+ attachment.setProperty(
+ "loadAction",
+ @intFromEnum(@as(
+ mtl.MTLLoadAction,
+ if (at.clear_color != null)
+ .clear
+ else
+ .load,
+ )),
+ );
+ attachment.setProperty(
+ "storeAction",
+ @intFromEnum(mtl.MTLStoreAction.store),
+ );
+ attachment.setProperty("texture", switch (at.target) {
+ .texture => |t| t.texture.value,
+ .target => |t| t.texture.value,
+ });
+ if (at.clear_color) |c| attachment.setProperty(
+ "clearColor",
+ mtl.MTLClearColor{
+ .red = c[0],
+ .green = c[1],
+ .blue = c[2],
+ .alpha = c[3],
+ },
+ );
+ }
+
+ break :desc desc;
+ };
+
+ // MTLRenderCommandEncoder
+ const encoder = opts.command_buffer.msgSend(
+ objc.Object,
+ objc.sel("renderCommandEncoderWithDescriptor:"),
+ .{desc.value},
+ );
+
+ return .{ .encoder = encoder };
+}
+
+/// Add a step to this render pass.
+pub fn step(self: *const Self, s: Step) void {
+ if (s.draw.instance_count == 0) return;
+
+ // Set pipeline state
+ self.encoder.msgSend(
+ void,
+ objc.sel("setRenderPipelineState:"),
+ .{s.pipeline.state.value},
+ );
+
+ if (s.buffers.len > 0) {
+ // We reserve index 0 for the vertex buffer, this isn't very
+ // flexible but it lines up with the API we have for OpenGL.
+ if (s.buffers[0]) |buf| {
+ self.encoder.msgSend(
+ void,
+ objc.sel("setVertexBuffer:offset:atIndex:"),
+ .{ buf.value, @as(c_ulong, 0), @as(c_ulong, 0) },
+ );
+ self.encoder.msgSend(
+ void,
+ objc.sel("setFragmentBuffer:offset:atIndex:"),
+ .{ buf.value, @as(c_ulong, 0), @as(c_ulong, 0) },
+ );
+ }
+
+ // Set the rest of the buffers starting at index 2, this is
+ // so that we can use index 1 for the uniforms if present.
+ //
+ // Also, we set buffers (and textures) for both stages.
+ //
+ // Again, not very flexible, but it's consistent and predictable,
+ // and we need to treat the uniforms as special because of OpenGL.
+ //
+ // TODO: Maybe in the future add info to the pipeline struct which
+ // allows it to define a mapping between provided buffers and
+ // what index they get set at for the vertex / fragment stage.
+ for (s.buffers[1..], 2..) |b, i| if (b) |buf| {
+ self.encoder.msgSend(
+ void,
+ objc.sel("setVertexBuffer:offset:atIndex:"),
+ .{ buf.value, @as(c_ulong, 0), @as(c_ulong, i) },
+ );
+ self.encoder.msgSend(
+ void,
+ objc.sel("setFragmentBuffer:offset:atIndex:"),
+ .{ buf.value, @as(c_ulong, 0), @as(c_ulong, i) },
+ );
+ };
+ }
+
+ // Set the uniforms as buffer index 1 if present.
+ if (s.uniforms) |buf| {
+ self.encoder.msgSend(
+ void,
+ objc.sel("setVertexBuffer:offset:atIndex:"),
+ .{ buf.value, @as(c_ulong, 0), @as(c_ulong, 1) },
+ );
+ self.encoder.msgSend(
+ void,
+ objc.sel("setFragmentBuffer:offset:atIndex:"),
+ .{ buf.value, @as(c_ulong, 0), @as(c_ulong, 1) },
+ );
+ }
+
+ // Set textures.
+ for (s.textures, 0..) |t, i| if (t) |tex| {
+ self.encoder.msgSend(
+ void,
+ objc.sel("setVertexTexture:atIndex:"),
+ .{ tex.texture.value, @as(c_ulong, i) },
+ );
+ self.encoder.msgSend(
+ void,
+ objc.sel("setFragmentTexture:atIndex:"),
+ .{ tex.texture.value, @as(c_ulong, i) },
+ );
+ };
+
+ // Draw!
+ self.encoder.msgSend(
+ void,
+ objc.sel("drawPrimitives:vertexStart:vertexCount:instanceCount:"),
+ .{
+ @intFromEnum(s.draw.type),
+ @as(c_ulong, 0),
+ @as(c_ulong, s.draw.vertex_count),
+ @as(c_ulong, s.draw.instance_count),
+ },
+ );
+}
+
+/// Complete this render pass.
+/// This struct can no longer be used after calling this.
+pub fn complete(self: *const Self) void {
+ self.encoder.msgSend(void, objc.sel("endEncoding"), .{});
+}
diff --git a/src/renderer/metal/Target.zig b/src/renderer/metal/Target.zig
new file mode 100644
index 000000000..fa62d3014
--- /dev/null
+++ b/src/renderer/metal/Target.zig
@@ -0,0 +1,110 @@
+//! Represents a render target.
+//!
+//! In this case, an IOSurface-backed MTLTexture.
+const Self = @This();
+
+const std = @import("std");
+const Allocator = std.mem.Allocator;
+const assert = std.debug.assert;
+const builtin = @import("builtin");
+const objc = @import("objc");
+const macos = @import("macos");
+const graphics = macos.graphics;
+const IOSurface = macos.iosurface.IOSurface;
+
+const mtl = @import("api.zig");
+
+const log = std.log.scoped(.metal);
+
+/// Options for initializing a Target
+pub const Options = struct {
+ /// MTLDevice
+ device: objc.Object,
+
+ /// Desired width
+ width: usize,
+ /// Desired height
+ height: usize,
+
+ /// Pixel format for the MTLTexture
+ pixel_format: mtl.MTLPixelFormat,
+ /// Storage mode for the MTLTexture
+ storage_mode: mtl.MTLResourceOptions.StorageMode,
+};
+
+/// The underlying IOSurface.
+surface: *IOSurface,
+
+/// The underlying MTLTexture.
+texture: objc.Object,
+
+/// Current width of this target.
+width: usize,
+/// Current height of this target.
+height: usize,
+
+pub fn init(opts: Options) !Self {
+ // We set our surface's color space to Display P3.
+ // This allows us to have "Apple-style" alpha blending,
+ // since it seems to be the case that Apple apps like
+ // Terminal and TextEdit render text in the display's
+ // color space using converted colors, which reduces,
+ // but does not fully eliminate blending artifacts.
+ const colorspace = try graphics.ColorSpace.createNamed(.displayP3);
+ defer colorspace.release();
+
+ const surface = try IOSurface.init(.{
+ .width = @intCast(opts.width),
+ .height = @intCast(opts.height),
+ .pixel_format = .@"32BGRA",
+ .bytes_per_element = 4,
+ .colorspace = colorspace,
+ });
+
+ // Create our descriptor
+ const desc = init: {
+ const Class = objc.getClass("MTLTextureDescriptor").?;
+ const id_alloc = Class.msgSend(objc.Object, objc.sel("alloc"), .{});
+ const id_init = id_alloc.msgSend(objc.Object, objc.sel("init"), .{});
+ break :init id_init;
+ };
+ errdefer desc.msgSend(void, objc.sel("release"), .{});
+
+ // Set our properties
+ desc.setProperty("width", @as(c_ulong, @intCast(opts.width)));
+ desc.setProperty("height", @as(c_ulong, @intCast(opts.height)));
+ desc.setProperty("pixelFormat", @intFromEnum(opts.pixel_format));
+ desc.setProperty("usage", mtl.MTLTextureUsage{ .render_target = true });
+ desc.setProperty(
+ "resourceOptions",
+ mtl.MTLResourceOptions{
+ // Indicate that the CPU writes to this resource but never reads it.
+ .cpu_cache_mode = .write_combined,
+ .storage_mode = opts.storage_mode,
+ },
+ );
+
+ const id = opts.device.msgSend(
+ ?*anyopaque,
+ objc.sel("newTextureWithDescriptor:iosurface:plane:"),
+ .{
+ desc,
+ surface,
+ @as(c_ulong, 0),
+ },
+ ) orelse return error.MetalFailed;
+
+ const texture = objc.Object.fromId(id);
+
+ return .{
+ .surface = surface,
+ .texture = texture,
+ .width = opts.width,
+ .height = opts.height,
+ };
+}
+
+pub fn deinit(self: *Self) void {
+ self.surface.deinit();
+ self.texture.release();
+}
diff --git a/src/renderer/metal/Texture.zig b/src/renderer/metal/Texture.zig
new file mode 100644
index 000000000..32820f8fc
--- /dev/null
+++ b/src/renderer/metal/Texture.zig
@@ -0,0 +1,201 @@
+//! Wrapper for handling textures.
+const Self = @This();
+
+const std = @import("std");
+const Allocator = std.mem.Allocator;
+const assert = std.debug.assert;
+const builtin = @import("builtin");
+const objc = @import("objc");
+
+const mtl = @import("api.zig");
+const Metal = @import("../Metal.zig");
+
+const log = std.log.scoped(.metal);
+
+/// Options for initializing a texture.
+pub const Options = struct {
+ /// MTLDevice
+ device: objc.Object,
+ pixel_format: mtl.MTLPixelFormat,
+ resource_options: mtl.MTLResourceOptions,
+};
+
+/// The underlying MTLTexture Object.
+texture: objc.Object,
+
+/// The width of this texture.
+width: usize,
+/// The height of this texture.
+height: usize,
+
+/// Bytes per pixel for this texture.
+bpp: usize,
+
+pub const Error = error{
+ /// A Metal API call failed.
+ MetalFailed,
+};
+
+/// Initialize a texture
+pub fn init(
+ opts: Options,
+ width: usize,
+ height: usize,
+ data: ?[]const u8,
+) Error!Self {
+ // Create our descriptor
+ const desc = init: {
+ const Class = objc.getClass("MTLTextureDescriptor").?;
+ const id_alloc = Class.msgSend(objc.Object, objc.sel("alloc"), .{});
+ const id_init = id_alloc.msgSend(objc.Object, objc.sel("init"), .{});
+ break :init id_init;
+ };
+ errdefer desc.msgSend(void, objc.sel("release"), .{});
+
+ // Set our properties
+ desc.setProperty("pixelFormat", @intFromEnum(opts.pixel_format));
+ desc.setProperty("width", @as(c_ulong, width));
+ desc.setProperty("height", @as(c_ulong, height));
+ desc.setProperty("resourceOptions", opts.resource_options);
+
+ // Initialize
+ const id = opts.device.msgSend(
+ ?*anyopaque,
+ objc.sel("newTextureWithDescriptor:"),
+ .{desc},
+ ) orelse return error.MetalFailed;
+
+ const self: Self = .{
+ .texture = objc.Object.fromId(id),
+ .width = width,
+ .height = height,
+ .bpp = bppOf(opts.pixel_format),
+ };
+
+ // If we have data, we set it here.
+ if (data) |d| {
+ assert(d.len == width * height * self.bpp);
+ try self.replaceRegion(0, 0, width, height, d);
+ }
+
+ return self;
+}
+
+pub fn deinit(self: Self) void {
+ self.texture.release();
+}
+
+/// Replace a region of the texture with the provided data.
+///
+/// Does NOT check the dimensions of the data to ensure correctness.
+pub fn replaceRegion(
+ self: Self,
+ x: usize,
+ y: usize,
+ width: usize,
+ height: usize,
+ data: []const u8,
+) error{}!void {
+ self.texture.msgSend(
+ void,
+ objc.sel("replaceRegion:mipmapLevel:withBytes:bytesPerRow:"),
+ .{
+ mtl.MTLRegion{
+ .origin = .{ .x = x, .y = y, .z = 0 },
+ .size = .{
+ .width = @intCast(width),
+ .height = @intCast(height),
+ .depth = 1,
+ },
+ },
+ @as(c_ulong, 0),
+ @as(*const anyopaque, data.ptr),
+ @as(c_ulong, self.bpp * width),
+ },
+ );
+}
+
+/// Returns the bytes per pixel for the provided pixel format
+fn bppOf(pixel_format: mtl.MTLPixelFormat) usize {
+ return switch (pixel_format) {
+ // Invalid
+ .invalid => @panic("invalid pixel format"),
+
+ // Weird formats I was too lazy to get the sizes of
+ else => @panic("pixel format size unknown (unlikely that this format was actually used, could be memory corruption)"),
+
+ // 8-bit pixel formats
+ .a8unorm,
+ .r8unorm,
+ .r8unorm_srgb,
+ .r8snorm,
+ .r8uint,
+ .r8sint,
+ .rg8unorm,
+ .rg8unorm_srgb,
+ .rg8snorm,
+ .rg8uint,
+ .rg8sint,
+ .stencil8,
+ => 1,
+
+ // 16-bit pixel formats
+ .r16unorm,
+ .r16snorm,
+ .r16uint,
+ .r16sint,
+ .r16float,
+ .rg16unorm,
+ .rg16snorm,
+ .rg16uint,
+ .rg16sint,
+ .rg16float,
+ .b5g6r5unorm,
+ .a1bgr5unorm,
+ .abgr4unorm,
+ .bgr5a1unorm,
+ .depth16unorm,
+ => 2,
+
+ // 32-bit pixel formats
+ .rgba8unorm,
+ .rgba8unorm_srgb,
+ .rgba8snorm,
+ .rgba8uint,
+ .rgba8sint,
+ .bgra8unorm,
+ .bgra8unorm_srgb,
+ .rgb10a2unorm,
+ .rgb10a2uint,
+ .rg11b10float,
+ .rgb9e5float,
+ .bgr10a2unorm,
+ .bgr10_xr,
+ .bgr10_xr_srgb,
+ .r32uint,
+ .r32sint,
+ .r32float,
+ .depth32float,
+ .depth24unorm_stencil8,
+ => 4,
+
+ // 64-bit pixel formats
+ .rg32uint,
+ .rg32sint,
+ .rg32float,
+ .rgba16unorm,
+ .rgba16snorm,
+ .rgba16uint,
+ .rgba16sint,
+ .rgba16float,
+ .bgra10_xr,
+ .bgra10_xr_srgb,
+ => 8,
+
+ // 128-bit pixel formats,
+ .rgba32uint,
+ .rgba32sint,
+ .rgba32float,
+ => 128,
+ };
+}
diff --git a/src/renderer/metal/api.zig b/src/renderer/metal/api.zig
index 46cb4f6bc..e1daa6848 100644
--- a/src/renderer/metal/api.zig
+++ b/src/renderer/metal/api.zig
@@ -1,4 +1,10 @@
//! This file contains the definitions of the Metal API that we use.
+//!
+//! Because the online Apple developer docs have recently (as of January 2025)
+//! been changed to hide enum values, `Metal-cpp` has been used as a reference
+//! source instead.
+//!
+//! Ref: https://developer.apple.com/metal/cpp/
/// https://developer.apple.com/documentation/metal/mtlcommandbufferstatus?language=objc
pub const MTLCommandBufferStatus = enum(c_ulong) {
@@ -22,6 +28,10 @@ pub const MTLLoadAction = enum(c_ulong) {
pub const MTLStoreAction = enum(c_ulong) {
dont_care = 0,
store = 1,
+ multisample_resolve = 2,
+ store_and_multisample_resolve = 3,
+ unknown = 4,
+ custom_sample_depth_store = 5,
};
/// https://developer.apple.com/documentation/metal/mtlresourceoptions?language=objc
@@ -73,16 +83,60 @@ pub const MTLIndexType = enum(c_ulong) {
/// https://developer.apple.com/documentation/metal/mtlvertexformat?language=objc
pub const MTLVertexFormat = enum(c_ulong) {
+ invalid = 0,
+ uchar2 = 1,
+ uchar3 = 2,
uchar4 = 3,
+ char2 = 4,
+ char3 = 5,
+ char4 = 6,
+ uchar2normalized = 7,
+ uchar3normalized = 8,
+ uchar4normalized = 9,
+ char2normalized = 10,
+ char3normalized = 11,
+ char4normalized = 12,
ushort2 = 13,
+ ushort3 = 14,
+ ushort4 = 15,
short2 = 16,
+ short3 = 17,
+ short4 = 18,
+ ushort2normalized = 19,
+ ushort3normalized = 20,
+ ushort4normalized = 21,
+ short2normalized = 22,
+ short3normalized = 23,
+ short4normalized = 24,
+ half2 = 25,
+ half3 = 26,
+ half4 = 27,
+ float = 28,
float2 = 29,
+ float3 = 30,
float4 = 31,
+ int = 32,
int2 = 33,
+ int3 = 34,
+ int4 = 35,
uint = 36,
uint2 = 37,
+ uint3 = 38,
uint4 = 39,
+ int1010102normalized = 40,
+ uint1010102normalized = 41,
+ uchar4normalized_bgra = 42,
uchar = 45,
+ char = 46,
+ ucharnormalized = 47,
+ charnormalized = 48,
+ ushort = 49,
+ short = 50,
+ ushortnormalized = 51,
+ shortnormalized = 52,
+ half = 53,
+ floatrg11b10 = 54,
+ floatrgb9e5 = 55,
};
/// https://developer.apple.com/documentation/metal/mtlvertexstepfunction?language=objc
@@ -90,20 +144,158 @@ pub const MTLVertexStepFunction = enum(c_ulong) {
constant = 0,
per_vertex = 1,
per_instance = 2,
+ per_patch = 3,
+ per_patch_control_point = 4,
};
/// https://developer.apple.com/documentation/metal/mtlpixelformat?language=objc
pub const MTLPixelFormat = enum(c_ulong) {
+ invalid = 0,
+ a8unorm = 1,
r8unorm = 10,
+ r8unorm_srgb = 11,
+ r8snorm = 12,
+ r8uint = 13,
+ r8sint = 14,
+ r16unorm = 20,
+ r16snorm = 22,
+ r16uint = 23,
+ r16sint = 24,
+ r16float = 25,
+ rg8unorm = 30,
+ rg8unorm_srgb = 31,
+ rg8snorm = 32,
+ rg8uint = 33,
+ rg8sint = 34,
+ b5g6r5unorm = 40,
+ a1bgr5unorm = 41,
+ abgr4unorm = 42,
+ bgr5a1unorm = 43,
+ r32uint = 53,
+ r32sint = 54,
+ r32float = 55,
+ rg16unorm = 60,
+ rg16snorm = 62,
+ rg16uint = 63,
+ rg16sint = 64,
+ rg16float = 65,
rgba8unorm = 70,
rgba8unorm_srgb = 71,
+ rgba8snorm = 72,
rgba8uint = 73,
+ rgba8sint = 74,
bgra8unorm = 80,
bgra8unorm_srgb = 81,
+ rgb10a2unorm = 90,
+ rgb10a2uint = 91,
+ rg11b10float = 92,
+ rgb9e5float = 93,
+ bgr10a2unorm = 94,
+ bgr10_xr = 554,
+ bgr10_xr_srgb = 555,
+ rg32uint = 103,
+ rg32sint = 104,
+ rg32float = 105,
+ rgba16unorm = 110,
+ rgba16snorm = 112,
+ rgba16uint = 113,
+ rgba16sint = 114,
+ rgba16float = 115,
+ bgra10_xr = 552,
+ bgra10_xr_srgb = 553,
+ rgba32uint = 123,
+ rgba32sint = 124,
+ rgba32float = 125,
+ bc1_rgba = 130,
+ bc1_rgba_srgb = 131,
+ bc2_rgba = 132,
+ bc2_rgba_srgb = 133,
+ bc3_rgba = 134,
+ bc3_rgba_srgb = 135,
+ bc4_runorm = 140,
+ bc4_rsnorm = 141,
+ bc5_rgunorm = 142,
+ bc5_rgsnorm = 143,
+ bc6h_rgbfloat = 150,
+ bc6h_rgbufloat = 151,
+ bc7_rgbaunorm = 152,
+ bc7_rgbaunorm_srgb = 153,
+ pvrtc_rgb_2bpp = 160,
+ pvrtc_rgb_2bpp_srgb = 161,
+ pvrtc_rgb_4bpp = 162,
+ pvrtc_rgb_4bpp_srgb = 163,
+ pvrtc_rgba_2bpp = 164,
+ pvrtc_rgba_2bpp_srgb = 165,
+ pvrtc_rgba_4bpp = 166,
+ pvrtc_rgba_4bpp_srgb = 167,
+ eac_r11unorm = 170,
+ eac_r11snorm = 172,
+ eac_rg11unorm = 174,
+ eac_rg11snorm = 176,
+ eac_rgba8 = 178,
+ eac_rgba8_srgb = 179,
+ etc2_rgb8 = 180,
+ etc2_rgb8_srgb = 181,
+ etc2_rgb8a1 = 182,
+ etc2_rgb8a1_srgb = 183,
+ astc_4x4_srgb = 186,
+ astc_5x4_srgb = 187,
+ astc_5x5_srgb = 188,
+ astc_6x5_srgb = 189,
+ astc_6x6_srgb = 190,
+ astc_8x5_srgb = 192,
+ astc_8x6_srgb = 193,
+ astc_8x8_srgb = 194,
+ astc_10x5_srgb = 195,
+ astc_10x6_srgb = 196,
+ astc_10x8_srgb = 197,
+ astc_10x10_srgb = 198,
+ astc_12x10_srgb = 199,
+ astc_12x12_srgb = 200,
+ astc_4x4_ldr = 204,
+ astc_5x4_ldr = 205,
+ astc_5x5_ldr = 206,
+ astc_6x5_ldr = 207,
+ astc_6x6_ldr = 208,
+ astc_8x5_ldr = 210,
+ astc_8x6_ldr = 211,
+ astc_8x8_ldr = 212,
+ astc_10x5_ldr = 213,
+ astc_10x6_ldr = 214,
+ astc_10x8_ldr = 215,
+ astc_10x10_ldr = 216,
+ astc_12x10_ldr = 217,
+ astc_12x12_ldr = 218,
+ astc_4x4_hdr = 222,
+ astc_5x4_hdr = 223,
+ astc_5x5_hdr = 224,
+ astc_6x5_hdr = 225,
+ astc_6x6_hdr = 226,
+ astc_8x5_hdr = 228,
+ astc_8x6_hdr = 229,
+ astc_8x8_hdr = 230,
+ astc_10x5_hdr = 231,
+ astc_10x6_hdr = 232,
+ astc_10x8_hdr = 233,
+ astc_10x10_hdr = 234,
+ astc_12x10_hdr = 235,
+ astc_12x12_hdr = 236,
+ gbgr422 = 240,
+ bgrg422 = 241,
+ depth16unorm = 250,
+ depth32float = 252,
+ stencil8 = 253,
+ depth24unorm_stencil8 = 255,
+ depth32float_stencil8 = 260,
+ x32_stencil8 = 261,
+ x24_stencil8 = 262,
};
/// https://developer.apple.com/documentation/metal/mtlpurgeablestate?language=objc
pub const MTLPurgeableState = enum(c_ulong) {
+ keep_current = 1,
+ non_volatile = 2,
+ @"volatile" = 3,
empty = 4,
};
@@ -155,13 +347,48 @@ pub const MTLBlendOperation = enum(c_ulong) {
max = 4,
};
-/// https://developer.apple.com/documentation/metal/mtltextureusage?language=objc<D-j>
-pub const MTLTextureUsage = enum(c_ulong) {
- unknown = 0,
- shader_read = 1,
- shader_write = 2,
- render_target = 4,
- pixel_format_view = 8,
+/// https://developer.apple.com/documentation/metal/mtltextureusage?language=objc
+pub const MTLTextureUsage = packed struct(c_ulong) {
+ /// https://developer.apple.com/documentation/metal/mtltextureusage/shaderread?language=objc
+ shader_read: bool = false, // TextureUsageShaderRead = 1,
+
+ /// https://developer.apple.com/documentation/metal/mtltextureusage/shaderwrite?language=objc
+ shader_write: bool = false, // TextureUsageShaderWrite = 2,
+
+ /// https://developer.apple.com/documentation/metal/mtltextureusage/rendertarget?language=objc
+ render_target: bool = false, // TextureUsageRenderTarget = 4,
+
+ _reserved: u1 = 0, // The enum skips from 4 to 16, 8 has no documented use.
+
+ /// https://developer.apple.com/documentation/metal/mtltextureusage/pixelformatview?language=objc
+ pixel_format_view: bool = false, // TextureUsagePixelFormatView = 16,
+
+ /// https://developer.apple.com/documentation/metal/mtltextureusage/shaderatomic?language=objc
+ shader_atomic: bool = false, // TextureUsageShaderAtomic = 32,
+
+ __reserved: @Type(.{ .int = .{
+ .signedness = .unsigned,
+ .bits = @bitSizeOf(c_ulong) - 6,
+ } }) = 0,
+
+ /// https://developer.apple.com/documentation/metal/mtltextureusage/unknown?language=objc
+ const unknown: MTLTextureUsage = @bitCast(0); // TextureUsageUnknown = 0,
+};
+
+/// https://developer.apple.com/documentation/metal/mtlbarrierscope?language=objc
+pub const MTLBarrierScope = enum(c_ulong) {
+ buffers = 1,
+ textures = 2,
+ render_targets = 4,
+};
+
+/// https://developer.apple.com/documentation/metal/mtlrenderstages?language=objc
+pub const MTLRenderStage = enum(c_ulong) {
+ vertex = 1,
+ fragment = 2,
+ tile = 4,
+ object = 8,
+ mesh = 16,
};
pub const MTLClearColor = extern struct {
diff --git a/src/renderer/metal/buffer.zig b/src/renderer/metal/buffer.zig
index 4128e297b..43320a60b 100644
--- a/src/renderer/metal/buffer.zig
+++ b/src/renderer/metal/buffer.zig
@@ -5,9 +5,17 @@ const objc = @import("objc");
const macos = @import("macos");
const mtl = @import("api.zig");
+const Metal = @import("../Metal.zig");
const log = std.log.scoped(.metal);
+/// Options for initializing a buffer.
+pub const Options = struct {
+ /// MTLDevice
+ device: objc.Object,
+ resource_options: mtl.MTLResourceOptions,
+};
+
/// Metal data storage for a certain set of equal types. This is usually
/// used for vertex buffers, etc. This helpful wrapper makes it easy to
/// prealloc, shrink, grow, sync, buffers with Metal.
@@ -15,74 +23,57 @@ pub fn Buffer(comptime T: type) type {
return struct {
const Self = @This();
- /// The resource options for this buffer.
- options: mtl.MTLResourceOptions,
+ /// The options this buffer was initialized with.
+ opts: Options,
+
+ /// The underlying MTLBuffer object.
+ buffer: objc.Object,
- buffer: objc.Object, // MTLBuffer
+ /// The allocated length of the buffer.
+ /// Note that this is the number
+ /// of `T`s not the size in bytes.
+ len: usize,
/// Initialize a buffer with the given length pre-allocated.
- pub fn init(
- device: objc.Object,
- len: usize,
- options: mtl.MTLResourceOptions,
- ) !Self {
- const buffer = device.msgSend(
+ pub fn init(opts: Options, len: usize) !Self {
+ const buffer = opts.device.msgSend(
objc.Object,
objc.sel("newBufferWithLength:options:"),
.{
@as(c_ulong, @intCast(len * @sizeOf(T))),
- options,
+ opts.resource_options,
},
);
- return .{ .buffer = buffer, .options = options };
+ return .{ .buffer = buffer, .opts = opts, .len = len };
}
/// Init the buffer filled with the given data.
- pub fn initFill(
- device: objc.Object,
- data: []const T,
- options: mtl.MTLResourceOptions,
- ) !Self {
- const buffer = device.msgSend(
+ pub fn initFill(opts: Options, data: []const T) !Self {
+ const buffer = opts.device.msgSend(
objc.Object,
objc.sel("newBufferWithBytes:length:options:"),
.{
@as(*const anyopaque, @ptrCast(data.ptr)),
@as(c_ulong, @intCast(data.len * @sizeOf(T))),
- options,
+ opts.resource_options,
},
);
- return .{ .buffer = buffer, .options = options };
+ return .{ .buffer = buffer, .opts = opts, .len = data.len };
}
- pub fn deinit(self: *Self) void {
+ pub fn deinit(self: *const Self) void {
self.buffer.msgSend(void, objc.sel("release"), .{});
}
- /// Get the buffer contents as a slice of T. The contents are
- /// mutable. The contents may or may not be automatically synced
- /// depending on the buffer storage mode. See the Metal docs.
- pub fn contents(self: *Self) ![]T {
- const len_bytes = self.buffer.getProperty(c_ulong, "length");
- assert(@mod(len_bytes, @sizeOf(T)) == 0);
- const len = @divExact(len_bytes, @sizeOf(T));
- const ptr = self.buffer.msgSend(
- ?[*]T,
- objc.sel("contents"),
- .{},
- ).?;
- return ptr[0..len];
- }
-
/// Sync new contents to the buffer. The data is expected to be the
/// complete contents of the buffer. If the amount of data is larger
/// than the buffer length, the buffer will be reallocated.
///
/// If the amount of data is smaller than the buffer length, the
/// remaining data in the buffer is left untouched.
- pub fn sync(self: *Self, device: objc.Object, data: []const T) !void {
+ pub fn sync(self: *Self, data: []const T) !void {
// If we need more bytes than our buffer has, we need to reallocate.
const req_bytes = data.len * @sizeOf(T);
const avail_bytes = self.buffer.getProperty(c_ulong, "length");
@@ -92,12 +83,12 @@ pub fn Buffer(comptime T: type) type {
// Allocate a new buffer with enough to hold double what we require.
const size = req_bytes * 2;
- self.buffer = device.msgSend(
+ self.buffer = self.opts.device.msgSend(
objc.Object,
objc.sel("newBufferWithLength:options:"),
.{
@as(c_ulong, @intCast(size * @sizeOf(T))),
- self.options,
+ self.opts.resource_options,
},
);
}
@@ -123,7 +114,7 @@ pub fn Buffer(comptime T: type) type {
// we need to signal Metal to synchronize the buffer data.
//
// Ref: https://developer.apple.com/documentation/metal/synchronizing-a-managed-resource-in-macos?language=objc
- if (self.options.storage_mode == .managed) {
+ if (self.opts.resource_options.storage_mode == .managed) {
self.buffer.msgSend(
void,
"didModifyRange:",
@@ -134,7 +125,7 @@ pub fn Buffer(comptime T: type) type {
/// Like Buffer.sync but takes data from an array of ArrayLists,
/// rather than a single array. Returns the number of items synced.
- pub fn syncFromArrayLists(self: *Self, device: objc.Object, lists: []std.ArrayListUnmanaged(T)) !usize {
+ pub fn syncFromArrayLists(self: *Self, lists: []const std.ArrayListUnmanaged(T)) !usize {
var total_len: usize = 0;
for (lists) |list| {
total_len += list.items.len;
@@ -149,12 +140,12 @@ pub fn Buffer(comptime T: type) type {
// Allocate a new buffer with enough to hold double what we require.
const size = req_bytes * 2;
- self.buffer = device.msgSend(
+ self.buffer = self.opts.device.msgSend(
objc.Object,
objc.sel("newBufferWithLength:options:"),
.{
@as(c_ulong, @intCast(size * @sizeOf(T))),
- self.options,
+ self.opts.resource_options,
},
);
}
@@ -181,7 +172,7 @@ pub fn Buffer(comptime T: type) type {
// we need to signal Metal to synchronize the buffer data.
//
// Ref: https://developer.apple.com/documentation/metal/synchronizing-a-managed-resource-in-macos?language=objc
- if (self.options.storage_mode == .managed) {
+ if (self.opts.resource_options.storage_mode == .managed) {
self.buffer.msgSend(
void,
"didModifyRange:",
diff --git a/src/renderer/metal/cell.zig b/src/renderer/metal/cell.zig
deleted file mode 100644
index 61b8887fd..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 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: []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 7d2599308..000000000
--- a/src/renderer/metal/image.zig
+++ /dev/null
@@ -1,466 +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 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: objc.Object, // MTLTexture
-
- /// The image is uploaded but is scheduled to be unloaded.
- unload_pending: []u8,
- unload_ready: objc.Object, // MTLTexture
- unload_replace: struct { []u8, objc.Object },
-
- pub const Replace = struct {
- texture: objc.Object,
- 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.msgSend(void, objc.sel("release"), .{});
- },
-
- .replace_gray_alpha => |r| {
- alloc.free(r.pending.dataSlice(2));
- r.texture.msgSend(void, objc.sel("release"), .{});
- },
-
- .replace_rgb => |r| {
- alloc.free(r.pending.dataSlice(3));
- r.texture.msgSend(void, objc.sel("release"), .{});
- },
-
- .replace_rgba => |r| {
- alloc.free(r.pending.dataSlice(4));
- r.texture.msgSend(void, objc.sel("release"), .{});
- },
-
- .unload_replace => |r| {
- alloc.free(r[0]);
- r[1].msgSend(void, objc.sel("release"), .{});
- },
-
- .ready,
- .unload_ready,
- => |obj| obj.msgSend(void, objc.sel("release"), .{}),
- }
- }
-
- /// 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: objc.Object = 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,
- device: objc.Object,
- /// Storage mode for the MTLTexture object
- storage_mode: mtl.MTLResourceOptions.StorageMode,
- ) !void {
- // 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 initTexture(p, device, storage_mode);
- errdefer texture.msgSend(void, objc.sel("release"), .{});
-
- // Upload our data
- const d = self.depth();
- texture.msgSend(
- void,
- objc.sel("replaceRegion:mipmapLevel:withBytes:bytesPerRow:"),
- .{
- mtl.MTLRegion{
- .origin = .{ .x = 0, .y = 0, .z = 0 },
- .size = .{
- .width = @intCast(p.width),
- .height = @intCast(p.height),
- .depth = 1,
- },
- },
- @as(c_ulong, 0),
- @as(*const anyopaque, p.data),
- @as(c_ulong, d * p.width),
- },
- );
-
- // 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,
- };
- }
-
- fn initTexture(
- p: Pending,
- device: objc.Object,
- /// Storage mode for the MTLTexture object
- storage_mode: mtl.MTLResourceOptions.StorageMode,
- ) !objc.Object {
- // Create our descriptor
- const desc = init: {
- const Class = objc.getClass("MTLTextureDescriptor").?;
- const id_alloc = Class.msgSend(objc.Object, objc.sel("alloc"), .{});
- const id_init = id_alloc.msgSend(objc.Object, objc.sel("init"), .{});
- break :init id_init;
- };
-
- // Set our properties
- desc.setProperty("pixelFormat", @intFromEnum(mtl.MTLPixelFormat.rgba8unorm_srgb));
- desc.setProperty("width", @as(c_ulong, @intCast(p.width)));
- desc.setProperty("height", @as(c_ulong, @intCast(p.height)));
-
- desc.setProperty(
- "resourceOptions",
- mtl.MTLResourceOptions{
- // Indicate that the CPU writes to this resource but never reads it.
- .cpu_cache_mode = .write_combined,
- .storage_mode = storage_mode,
- },
- );
-
- // Initialize
- const id = device.msgSend(
- ?*anyopaque,
- objc.sel("newTextureWithDescriptor:"),
- .{desc},
- ) orelse return error.MetalFailed;
-
- return objc.Object.fromId(id);
- }
-};
diff --git a/src/renderer/metal/sampler.zig b/src/renderer/metal/sampler.zig
deleted file mode 100644
index c7a04df3a..000000000
--- a/src/renderer/metal/sampler.zig
+++ /dev/null
@@ -1,38 +0,0 @@
-const std = @import("std");
-const Allocator = std.mem.Allocator;
-const assert = std.debug.assert;
-const objc = @import("objc");
-
-const mtl = @import("api.zig");
-
-pub const Sampler = struct {
- sampler: objc.Object,
-
- pub fn init(device: objc.Object) !Sampler {
- const desc = init: {
- const Class = objc.getClass("MTLSamplerDescriptor").?;
- const id_alloc = Class.msgSend(objc.Object, objc.sel("alloc"), .{});
- const id_init = id_alloc.msgSend(objc.Object, objc.sel("init"), .{});
- break :init id_init;
- };
- defer desc.msgSend(void, objc.sel("release"), .{});
- desc.setProperty("rAddressMode", @intFromEnum(mtl.MTLSamplerAddressMode.clamp_to_edge));
- desc.setProperty("sAddressMode", @intFromEnum(mtl.MTLSamplerAddressMode.clamp_to_edge));
- desc.setProperty("tAddressMode", @intFromEnum(mtl.MTLSamplerAddressMode.clamp_to_edge));
- desc.setProperty("minFilter", @intFromEnum(mtl.MTLSamplerMinMagFilter.linear));
- desc.setProperty("magFilter", @intFromEnum(mtl.MTLSamplerMinMagFilter.linear));
-
- const sampler = device.msgSend(
- objc.Object,
- objc.sel("newSamplerStateWithDescriptor:"),
- .{desc},
- );
- errdefer sampler.msgSend(void, objc.sel("release"), .{});
-
- return .{ .sampler = sampler };
- }
-
- pub fn deinit(self: *Sampler) void {
- self.sampler.msgSend(void, objc.sel("release"), .{});
- }
-};
diff --git a/src/renderer/metal/shaders.zig b/src/renderer/metal/shaders.zig
index 8fa170bf2..9fe0862ed 100644
--- a/src/renderer/metal/shaders.zig
+++ b/src/renderer/metal/shaders.zig
@@ -6,28 +6,110 @@ const objc = @import("objc");
const math = @import("../../math.zig");
const mtl = @import("api.zig");
+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: objc.Object,
-
- /// The cell background shader is the shader used to render the
- /// background of terminal cells.
- cell_bg_pipeline: objc.Object,
-
- /// The image shader is the shader used to render images for things
- /// like the Kitty image protocol.
- image_pipeline: objc.Object,
+ /// 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
/// against the output of the previous shader.
- post_pipelines: []const objc.Object,
+ post_pipelines: []const Pipeline,
+
+ /// Set to true when deinited, if you try to deinit a defunct set
+ /// of shaders it will just be ignored, to prevent double-free.
+ defunct: bool = false,
/// Initialize our shader set.
///
@@ -43,16 +125,26 @@ 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.msgSend(void, objc.sel("release"), .{});
+ var pipelines: PipelineCollection = undefined;
+
+ var initialized_pipelines: usize = 0;
- const cell_bg_pipeline = try initCellBgPipeline(device, library, pixel_format);
- errdefer cell_bg_pipeline.msgSend(void, objc.sel("release"), .{});
+ errdefer inline for (pipeline_descs, 0..) |pipeline, i| {
+ if (i < initialized_pipelines) {
+ @field(pipelines, pipeline[0]).deinit();
+ }
+ };
- const image_pipeline = try initImagePipeline(device, library, pixel_format);
- errdefer image_pipeline.msgSend(void, objc.sel("release"), .{});
+ inline for (pipeline_descs) |pipeline| {
+ @field(pipelines, pipeline[0]) = try pipeline[1].initPipeline(
+ device,
+ library,
+ pixel_format,
+ );
+ initialized_pipelines += 1;
+ }
- const post_pipelines: []const objc.Object = initPostPipelines(
+ const post_pipelines: []const Pipeline = initPostPipelines(
alloc,
device,
library,
@@ -66,47 +158,40 @@ pub const Shaders = struct {
break :err &.{};
};
errdefer if (post_pipelines.len > 0) {
- for (post_pipelines) |pipeline| pipeline.msgSend(void, objc.sel("release"), .{});
+ for (post_pipelines) |pipeline| pipeline.deinit();
alloc.free(post_pipelines);
};
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,
};
}
pub fn deinit(self: *Shaders, alloc: Allocator) void {
+ if (self.defunct) return;
+ self.defunct = true;
+
// Release our primary shaders
- self.cell_text_pipeline.msgSend(void, objc.sel("release"), .{});
- self.cell_bg_pipeline.msgSend(void, objc.sel("release"), .{});
- self.image_pipeline.msgSend(void, objc.sel("release"), .{});
+ inline for (pipeline_descs) |pipeline| {
+ @field(self.pipelines, pipeline[0]).deinit();
+ }
self.library.msgSend(void, objc.sel("release"), .{});
// Release our postprocess shaders
if (self.post_pipelines.len > 0) {
for (self.post_pipelines) |pipeline| {
- pipeline.msgSend(void, objc.sel("release"), .{});
+ pipeline.deinit();
}
alloc.free(self.post_pipelines);
}
}
};
-/// 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 aligmnments are copied from the
+ // Note: all of the explicit alignments are copied from the
// MSL developer reference just so that we can be sure that we got
// it all exactly right.
@@ -114,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),
@@ -140,25 +228,30 @@ pub const Uniforms = extern struct {
/// The background color for the whole surface.
bg_color: [4]u8 align(4),
- /// Whether the cursor is 2 cells wide.
- cursor_wide: bool align(1),
-
- /// Indicates that colors provided to the shader are already in
- /// the P3 color space, so they don't need to be converted from
- /// sRGB.
- use_display_p3: bool align(1),
-
- /// Indicates that the color attachments for the shaders have
- /// an `*_srgb` pixel format, which means the shaders need to
- /// output linear RGB colors rather than gamma encoded colors,
- /// since blending will be performed in linear space and then
- /// Metal itself will re-encode the colors for storage.
- use_linear_blending: bool align(1),
-
- /// Enables a weight correction step that makes text rendered
- /// with linear alpha blending have a similar apparent weight
- /// (thickness) to gamma-incorrect blending.
- use_linear_correction: bool align(1) = false,
+ /// Various booleans.
+ ///
+ /// TODO: Maybe put these in a packed struct, like for OpenGL.
+ bools: extern struct {
+ /// Whether the cursor is 2 cells wide.
+ cursor_wide: bool align(1),
+
+ /// Indicates that colors provided to the shader are already in
+ /// the P3 color space, so they don't need to be converted from
+ /// sRGB.
+ use_display_p3: bool align(1),
+
+ /// Indicates that the color attachments for the shaders have
+ /// an `*_srgb` pixel format, which means the shaders need to
+ /// output linear RGB colors rather than gamma encoded colors,
+ /// since blending will be performed in linear space and then
+ /// Metal itself will re-encode the colors for storage.
+ use_linear_blending: bool align(1),
+
+ /// Enables a weight correction step that makes text rendered
+ /// with linear alpha blending have a similar apparent weight
+ /// (thickness) to gamma-incorrect blending.
+ use_linear_correction: bool align(1) = false,
+ },
const PaddingExtend = packed struct(u8) {
left: bool = false,
@@ -169,21 +262,72 @@ pub const Uniforms = extern struct {
};
};
-/// The uniforms used for custom postprocess shaders.
-pub const PostUniforms = extern struct {
- // Note: all of the explicit aligmnments are copied from the
- // MSL developer reference just so that we can be sure that we got
- // it all exactly right.
- resolution: [3]f32 align(16),
- time: f32 align(4),
- time_delta: f32 align(4),
- frame_rate: f32 align(4),
- frame: i32 align(4),
- channel_time: [4][4]f32 align(16),
- channel_resolution: [4][4]f32 align(16),
- mouse: [4]f32 align(16),
- date: [4]f32 align(16),
- sample_rate: f32 align(4),
+/// 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.
@@ -214,15 +358,16 @@ fn initLibrary(device: objc.Object) !objc.Object {
return library;
}
-/// Initialize our custom shader pipelines. The shaders argument is a
-/// set of shader source code, not file paths.
+/// Initialize our custom shader pipelines.
+///
+/// The shaders argument is a set of shader source code, not file paths.
fn initPostPipelines(
alloc: Allocator,
device: objc.Object,
library: objc.Object,
shaders: []const [:0]const u8,
pixel_format: mtl.MTLPixelFormat,
-) ![]const objc.Object {
+) ![]const Pipeline {
// If we have no shaders, do nothing.
if (shaders.len == 0) return &.{};
@@ -230,10 +375,10 @@ fn initPostPipelines(
var i: usize = 0;
// Initialize our result set. If any error happens, we undo everything.
- var pipelines = try alloc.alloc(objc.Object, shaders.len);
+ var pipelines = try alloc.alloc(Pipeline, shaders.len);
errdefer {
for (pipelines[0..i]) |pipeline| {
- pipeline.msgSend(void, objc.sel("release"), .{});
+ pipeline.deinit();
}
alloc.free(pipelines);
}
@@ -259,7 +404,7 @@ fn initPostPipeline(
library: objc.Object,
data: [:0]const u8,
pixel_format: mtl.MTLPixelFormat,
-) !objc.Object {
+) !Pipeline {
// Create our library which has the shader source
const post_library = library: {
const source = try macos.foundation.String.createWithBytes(
@@ -282,437 +427,19 @@ fn initPostPipeline(
};
defer post_library.msgSend(void, objc.sel("release"), .{});
- // Get our vertex and fragment functions
- const func_vert = func_vert: {
- const str = try macos.foundation.String.createWithBytes(
- "full_screen_vertex",
- .utf8,
- false,
- );
- defer str.release();
-
- const ptr = library.msgSend(?*anyopaque, objc.sel("newFunctionWithName:"), .{str});
- break :func_vert objc.Object.fromId(ptr.?);
- };
- const func_frag = func_frag: {
- const str = try macos.foundation.String.createWithBytes(
- "main0",
- .utf8,
- false,
- );
- defer str.release();
-
- const ptr = post_library.msgSend(?*anyopaque, objc.sel("newFunctionWithName:"), .{str});
- break :func_frag objc.Object.fromId(ptr.?);
- };
- defer func_vert.msgSend(void, objc.sel("release"), .{});
- defer func_frag.msgSend(void, objc.sel("release"), .{});
-
- // Create our descriptor
- const desc = init: {
- const Class = objc.getClass("MTLRenderPipelineDescriptor").?;
- const id_alloc = Class.msgSend(objc.Object, objc.sel("alloc"), .{});
- const id_init = id_alloc.msgSend(objc.Object, objc.sel("init"), .{});
- break :init id_init;
- };
- defer desc.msgSend(void, objc.sel("release"), .{});
- desc.setProperty("vertexFunction", func_vert);
- desc.setProperty("fragmentFunction", func_frag);
-
- // Set our color attachment
- const attachments = objc.Object.fromId(desc.getProperty(?*anyopaque, "colorAttachments"));
- {
- const attachment = attachments.msgSend(
- objc.Object,
- objc.sel("objectAtIndexedSubscript:"),
- .{@as(c_ulong, 0)},
- );
-
- attachment.setProperty("pixelFormat", @intFromEnum(pixel_format));
- }
-
- // Make our state
- var err: ?*anyopaque = null;
- const pipeline_state = device.msgSend(
- objc.Object,
- objc.sel("newRenderPipelineStateWithDescriptor:error:"),
- .{ desc, &err },
- );
- try checkError(err);
-
- return pipeline_state;
-}
-
-/// 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,
-) !objc.Object {
- // Get our vertex and fragment functions
- const func_vert = func_vert: {
- const str = try macos.foundation.String.createWithBytes(
- "cell_text_vertex",
- .utf8,
- false,
- );
- defer str.release();
-
- const ptr = library.msgSend(?*anyopaque, objc.sel("newFunctionWithName:"), .{str});
- break :func_vert objc.Object.fromId(ptr.?);
- };
- const func_frag = func_frag: {
- const str = try macos.foundation.String.createWithBytes(
- "cell_text_fragment",
- .utf8,
- false,
- );
- defer str.release();
-
- const ptr = library.msgSend(?*anyopaque, objc.sel("newFunctionWithName:"), .{str});
- break :func_frag objc.Object.fromId(ptr.?);
- };
- defer func_vert.msgSend(void, objc.sel("release"), .{});
- defer func_frag.msgSend(void, objc.sel("release"), .{});
-
- // Create the vertex descriptor. The vertex descriptor describes the
- // data layout of the vertex inputs. We use indexed (or "instanced")
- // rendering, so this makes it so that each instance gets a single
- // Cell as input.
- const vertex_desc = vertex_desc: {
- const desc = init: {
- const Class = objc.getClass("MTLVertexDescriptor").?;
- const id_alloc = Class.msgSend(objc.Object, objc.sel("alloc"), .{});
- const id_init = id_alloc.msgSend(objc.Object, objc.sel("init"), .{});
- break :init id_init;
- };
-
- // Our attributes are the fields of the input
- const attrs = objc.Object.fromId(desc.getProperty(?*anyopaque, "attributes"));
- autoAttribute(CellText, attrs);
-
- // The layout describes how and when we fetch the next vertex input.
- const layouts = objc.Object.fromId(desc.getProperty(?*anyopaque, "layouts"));
- {
- const layout = layouts.msgSend(
- objc.Object,
- objc.sel("objectAtIndexedSubscript:"),
- .{@as(c_ulong, 0)},
- );
-
- // Access each Cell per instance, not per vertex.
- layout.setProperty("stepFunction", @intFromEnum(mtl.MTLVertexStepFunction.per_instance));
- layout.setProperty("stride", @as(c_ulong, @sizeOf(CellText)));
- }
-
- break :vertex_desc desc;
- };
- defer vertex_desc.msgSend(void, objc.sel("release"), .{});
-
- // Create our descriptor
- const desc = init: {
- const Class = objc.getClass("MTLRenderPipelineDescriptor").?;
- const id_alloc = Class.msgSend(objc.Object, objc.sel("alloc"), .{});
- const id_init = id_alloc.msgSend(objc.Object, objc.sel("init"), .{});
- break :init id_init;
- };
- defer desc.msgSend(void, objc.sel("release"), .{});
-
- // Set our properties
- desc.setProperty("vertexFunction", func_vert);
- desc.setProperty("fragmentFunction", func_frag);
- desc.setProperty("vertexDescriptor", vertex_desc);
-
- // Set our color attachment
- const attachments = objc.Object.fromId(desc.getProperty(?*anyopaque, "colorAttachments"));
- {
- const attachment = attachments.msgSend(
- objc.Object,
- objc.sel("objectAtIndexedSubscript:"),
- .{@as(c_ulong, 0)},
- );
-
- attachment.setProperty("pixelFormat", @intFromEnum(pixel_format));
-
- // Blending. This is required so that our text we render on top
- // of our drawable properly blends into the bg.
- attachment.setProperty("blendingEnabled", true);
- attachment.setProperty("rgbBlendOperation", @intFromEnum(mtl.MTLBlendOperation.add));
- attachment.setProperty("alphaBlendOperation", @intFromEnum(mtl.MTLBlendOperation.add));
- attachment.setProperty("sourceRGBBlendFactor", @intFromEnum(mtl.MTLBlendFactor.one));
- attachment.setProperty("sourceAlphaBlendFactor", @intFromEnum(mtl.MTLBlendFactor.one));
- attachment.setProperty("destinationRGBBlendFactor", @intFromEnum(mtl.MTLBlendFactor.one_minus_source_alpha));
- attachment.setProperty("destinationAlphaBlendFactor", @intFromEnum(mtl.MTLBlendFactor.one_minus_source_alpha));
- }
-
- // Make our state
- var err: ?*anyopaque = null;
- const pipeline_state = device.msgSend(
- objc.Object,
- objc.sel("newRenderPipelineStateWithDescriptor:error:"),
- .{ desc, &err },
- );
- try checkError(err);
- errdefer pipeline_state.msgSend(void, objc.sel("release"), .{});
-
- return pipeline_state;
-}
-
-/// 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,
-) !objc.Object {
- // Get our vertex and fragment functions
- const func_vert = func_vert: {
- const str = try macos.foundation.String.createWithBytes(
- "cell_bg_vertex",
- .utf8,
- false,
- );
- defer str.release();
-
- const ptr = library.msgSend(?*anyopaque, objc.sel("newFunctionWithName:"), .{str});
- break :func_vert objc.Object.fromId(ptr.?);
- };
- defer func_vert.msgSend(void, objc.sel("release"), .{});
- const func_frag = func_frag: {
- const str = try macos.foundation.String.createWithBytes(
- "cell_bg_fragment",
- .utf8,
- false,
- );
- defer str.release();
-
- const ptr = library.msgSend(?*anyopaque, objc.sel("newFunctionWithName:"), .{str});
- break :func_frag objc.Object.fromId(ptr.?);
- };
- defer func_frag.msgSend(void, objc.sel("release"), .{});
-
- // Create our descriptor
- const desc = init: {
- const Class = objc.getClass("MTLRenderPipelineDescriptor").?;
- const id_alloc = Class.msgSend(objc.Object, objc.sel("alloc"), .{});
- const id_init = id_alloc.msgSend(objc.Object, objc.sel("init"), .{});
- break :init id_init;
- };
- defer desc.msgSend(void, objc.sel("release"), .{});
-
- // Set our properties
- desc.setProperty("vertexFunction", func_vert);
- desc.setProperty("fragmentFunction", func_frag);
-
- // Set our color attachment
- const attachments = objc.Object.fromId(desc.getProperty(?*anyopaque, "colorAttachments"));
- {
- const attachment = attachments.msgSend(
- objc.Object,
- objc.sel("objectAtIndexedSubscript:"),
- .{@as(c_ulong, 0)},
- );
-
- attachment.setProperty("pixelFormat", @intFromEnum(pixel_format));
-
- // Blending. This is required so that our text we render on top
- // of our drawable properly blends into the bg.
- attachment.setProperty("blendingEnabled", true);
- attachment.setProperty("rgbBlendOperation", @intFromEnum(mtl.MTLBlendOperation.add));
- attachment.setProperty("alphaBlendOperation", @intFromEnum(mtl.MTLBlendOperation.add));
- attachment.setProperty("sourceRGBBlendFactor", @intFromEnum(mtl.MTLBlendFactor.one));
- attachment.setProperty("sourceAlphaBlendFactor", @intFromEnum(mtl.MTLBlendFactor.one));
- attachment.setProperty("destinationRGBBlendFactor", @intFromEnum(mtl.MTLBlendFactor.one_minus_source_alpha));
- attachment.setProperty("destinationAlphaBlendFactor", @intFromEnum(mtl.MTLBlendFactor.one_minus_source_alpha));
- }
-
- // Make our state
- var err: ?*anyopaque = null;
- const pipeline_state = device.msgSend(
- objc.Object,
- objc.sel("newRenderPipelineStateWithDescriptor:error:"),
- .{ desc, &err },
- );
- try checkError(err);
- errdefer pipeline_state.msgSend(void, objc.sel("release"), .{});
-
- return pipeline_state;
-}
-
-/// Initialize the image render pipeline for our shader library.
-fn initImagePipeline(
- device: objc.Object,
- library: objc.Object,
- pixel_format: mtl.MTLPixelFormat,
-) !objc.Object {
- // Get our vertex and fragment functions
- const func_vert = func_vert: {
- const str = try macos.foundation.String.createWithBytes(
- "image_vertex",
- .utf8,
- false,
- );
- defer str.release();
-
- const ptr = library.msgSend(?*anyopaque, objc.sel("newFunctionWithName:"), .{str});
- break :func_vert objc.Object.fromId(ptr.?);
- };
- const func_frag = func_frag: {
- const str = try macos.foundation.String.createWithBytes(
- "image_fragment",
- .utf8,
- false,
- );
- defer str.release();
-
- const ptr = library.msgSend(?*anyopaque, objc.sel("newFunctionWithName:"), .{str});
- break :func_frag objc.Object.fromId(ptr.?);
- };
- defer func_vert.msgSend(void, objc.sel("release"), .{});
- defer func_frag.msgSend(void, objc.sel("release"), .{});
-
- // Create the vertex descriptor. The vertex descriptor describes the
- // data layout of the vertex inputs. We use indexed (or "instanced")
- // rendering, so this makes it so that each instance gets a single
- // Image as input.
- const vertex_desc = vertex_desc: {
- const desc = init: {
- const Class = objc.getClass("MTLVertexDescriptor").?;
- const id_alloc = Class.msgSend(objc.Object, objc.sel("alloc"), .{});
- const id_init = id_alloc.msgSend(objc.Object, objc.sel("init"), .{});
- break :init id_init;
- };
-
- // Our attributes are the fields of the input
- const attrs = objc.Object.fromId(desc.getProperty(?*anyopaque, "attributes"));
- autoAttribute(Image, attrs);
-
- // The layout describes how and when we fetch the next vertex input.
- const layouts = objc.Object.fromId(desc.getProperty(?*anyopaque, "layouts"));
- {
- const layout = layouts.msgSend(
- objc.Object,
- objc.sel("objectAtIndexedSubscript:"),
- .{@as(c_ulong, 0)},
- );
-
- // Access each Image per instance, not per vertex.
- layout.setProperty("stepFunction", @intFromEnum(mtl.MTLVertexStepFunction.per_instance));
- layout.setProperty("stride", @as(c_ulong, @sizeOf(Image)));
- }
-
- break :vertex_desc desc;
- };
- defer vertex_desc.msgSend(void, objc.sel("release"), .{});
-
- // Create our descriptor
- const desc = init: {
- const Class = objc.getClass("MTLRenderPipelineDescriptor").?;
- const id_alloc = Class.msgSend(objc.Object, objc.sel("alloc"), .{});
- const id_init = id_alloc.msgSend(objc.Object, objc.sel("init"), .{});
- break :init id_init;
- };
- defer desc.msgSend(void, objc.sel("release"), .{});
-
- // Set our properties
- desc.setProperty("vertexFunction", func_vert);
- desc.setProperty("fragmentFunction", func_frag);
- desc.setProperty("vertexDescriptor", vertex_desc);
-
- // Set our color attachment
- const attachments = objc.Object.fromId(desc.getProperty(?*anyopaque, "colorAttachments"));
- {
- const attachment = attachments.msgSend(
- objc.Object,
- objc.sel("objectAtIndexedSubscript:"),
- .{@as(c_ulong, 0)},
- );
-
- attachment.setProperty("pixelFormat", @intFromEnum(pixel_format));
-
- // Blending. This is required so that our text we render on top
- // of our drawable properly blends into the bg.
- attachment.setProperty("blendingEnabled", true);
- attachment.setProperty("rgbBlendOperation", @intFromEnum(mtl.MTLBlendOperation.add));
- attachment.setProperty("alphaBlendOperation", @intFromEnum(mtl.MTLBlendOperation.add));
- attachment.setProperty("sourceRGBBlendFactor", @intFromEnum(mtl.MTLBlendFactor.one));
- attachment.setProperty("sourceAlphaBlendFactor", @intFromEnum(mtl.MTLBlendFactor.one));
- attachment.setProperty("destinationRGBBlendFactor", @intFromEnum(mtl.MTLBlendFactor.one_minus_source_alpha));
- attachment.setProperty("destinationAlphaBlendFactor", @intFromEnum(mtl.MTLBlendFactor.one_minus_source_alpha));
- }
-
- // Make our state
- var err: ?*anyopaque = null;
- const pipeline_state = device.msgSend(
- objc.Object,
- objc.sel("newRenderPipelineStateWithDescriptor:error:"),
- .{ desc, &err },
- );
- try checkError(err);
-
- return pipeline_state;
-}
-
-fn autoAttribute(T: type, attrs: objc.Object) void {
- inline for (@typeInfo(T).@"struct".fields, 0..) |field, i| {
- const offset = @offsetOf(T, field.name);
-
- const FT = switch (@typeInfo(field.type)) {
- .@"enum" => |e| e.tag_type,
- else => field.type,
- };
-
- const format = switch (FT) {
- [4]u8 => mtl.MTLVertexFormat.uchar4,
- [2]u16 => mtl.MTLVertexFormat.ushort2,
- [2]i16 => mtl.MTLVertexFormat.short2,
- [2]f32 => mtl.MTLVertexFormat.float2,
- [4]f32 => mtl.MTLVertexFormat.float4,
- [2]i32 => mtl.MTLVertexFormat.int2,
- u32 => mtl.MTLVertexFormat.uint,
- [2]u32 => mtl.MTLVertexFormat.uint2,
- [4]u32 => mtl.MTLVertexFormat.uint4,
- u8 => mtl.MTLVertexFormat.uchar,
- else => comptime unreachable,
- };
-
- const attr = attrs.msgSend(
- objc.Object,
- objc.sel("objectAtIndexedSubscript:"),
- .{@as(c_ulong, i)},
- );
-
- attr.setProperty("format", @intFromEnum(format));
- attr.setProperty("offset", @as(c_ulong, offset));
- attr.setProperty("bufferIndex", @as(c_ulong, 0));
- }
+ return try Pipeline.init(null, .{
+ .device = device,
+ .vertex_fn = "full_screen_vertex",
+ .fragment_fn = "main0",
+ .vertex_library = library,
+ .fragment_library = post_library,
+ .attachments = &.{
+ .{
+ .pixel_format = pixel_format,
+ .blending_enabled = false,
+ },
+ },
+ });
}
fn checkError(err_: ?*anyopaque) !void {
diff --git a/src/renderer/opengl/CellProgram.zig b/src/renderer/opengl/CellProgram.zig
deleted file mode 100644
index c4da8e233..000000000
--- a/src/renderer/opengl/CellProgram.zig
+++ /dev/null
@@ -1,196 +0,0 @@
-/// The OpenGL program for rendering terminal cells.
-const CellProgram = @This();
-
-const std = @import("std");
-const gl = @import("opengl");
-
-program: gl.Program,
-vao: gl.VertexArray,
-ebo: gl.Buffer,
-vbo: gl.Buffer,
-
-/// The raw structure that maps directly to the buffer sent to the vertex shader.
-/// This must be "extern" so that the field order is not reordered by the
-/// Zig compiler.
-pub const Cell = extern struct {
- /// vec2 grid_coord
- grid_col: u16,
- grid_row: u16,
-
- /// vec2 glyph_pos
- glyph_x: u32 = 0,
- glyph_y: u32 = 0,
-
- /// vec2 glyph_size
- glyph_width: u32 = 0,
- glyph_height: u32 = 0,
-
- /// vec2 glyph_offset
- glyph_offset_x: i32 = 0,
- glyph_offset_y: i32 = 0,
-
- /// vec4 color_in
- r: u8,
- g: u8,
- b: u8,
- a: u8,
-
- /// vec4 bg_color_in
- bg_r: u8,
- bg_g: u8,
- bg_b: u8,
- bg_a: u8,
-
- /// uint mode
- mode: CellMode,
-
- /// The width in grid cells that a rendering takes.
- grid_width: u8,
-};
-
-pub const CellMode = enum(u8) {
- bg = 1,
- fg = 2,
- fg_constrained = 3,
- fg_color = 7,
- fg_powerline = 15,
-
- // Non-exhaustive because masks change it
- _,
-
- /// Apply a mask to the mode.
- pub fn mask(self: CellMode, m: CellMode) CellMode {
- return @enumFromInt(@intFromEnum(self) | @intFromEnum(m));
- }
-
- pub fn isFg(self: CellMode) bool {
- // Since we use bit tricks below, we want to ensure the enum
- // doesn't change without us looking at this logic again.
- comptime {
- const info = @typeInfo(CellMode).@"enum";
- std.debug.assert(info.fields.len == 5);
- }
-
- return @intFromEnum(self) & @intFromEnum(@as(CellMode, .fg)) != 0;
- }
-};
-
-pub fn init() !CellProgram {
- // Load and compile our shaders.
- const program = try gl.Program.createVF(
- @embedFile("../shaders/cell.v.glsl"),
- @embedFile("../shaders/cell.f.glsl"),
- );
- errdefer program.destroy();
-
- // Set our cell dimensions
- const pbind = try program.use();
- defer pbind.unbind();
-
- // Set all of our texture indexes
- try program.setUniform("text", 0);
- try program.setUniform("text_color", 1);
-
- // Setup our VAO
- const vao = try gl.VertexArray.create();
- errdefer vao.destroy();
- const vaobind = try vao.bind();
- defer vaobind.unbind();
-
- // Element buffer (EBO)
- const ebo = try gl.Buffer.create();
- errdefer ebo.destroy();
- var ebobind = try ebo.bind(.element_array);
- defer ebobind.unbind();
- try ebobind.setData([6]u8{
- 0, 1, 3, // Top-left triangle
- 1, 2, 3, // Bottom-right triangle
- }, .static_draw);
-
- // Vertex buffer (VBO)
- const vbo = try gl.Buffer.create();
- errdefer vbo.destroy();
- var vbobind = try vbo.bind(.array);
- defer vbobind.unbind();
- var offset: usize = 0;
- try vbobind.attributeAdvanced(0, 2, gl.c.GL_UNSIGNED_SHORT, false, @sizeOf(Cell), offset);
- offset += 2 * @sizeOf(u16);
- try vbobind.attributeAdvanced(1, 2, gl.c.GL_UNSIGNED_INT, false, @sizeOf(Cell), offset);
- offset += 2 * @sizeOf(u32);
- try vbobind.attributeAdvanced(2, 2, gl.c.GL_UNSIGNED_INT, false, @sizeOf(Cell), offset);
- offset += 2 * @sizeOf(u32);
- try vbobind.attributeAdvanced(3, 2, gl.c.GL_INT, false, @sizeOf(Cell), offset);
- offset += 2 * @sizeOf(i32);
- try vbobind.attributeAdvanced(4, 4, gl.c.GL_UNSIGNED_BYTE, false, @sizeOf(Cell), offset);
- offset += 4 * @sizeOf(u8);
- try vbobind.attributeAdvanced(5, 4, gl.c.GL_UNSIGNED_BYTE, false, @sizeOf(Cell), offset);
- offset += 4 * @sizeOf(u8);
- try vbobind.attributeIAdvanced(6, 1, gl.c.GL_UNSIGNED_BYTE, @sizeOf(Cell), offset);
- offset += 1 * @sizeOf(u8);
- try vbobind.attributeIAdvanced(7, 1, gl.c.GL_UNSIGNED_BYTE, @sizeOf(Cell), offset);
- try vbobind.enableAttribArray(0);
- try vbobind.enableAttribArray(1);
- try vbobind.enableAttribArray(2);
- try vbobind.enableAttribArray(3);
- try vbobind.enableAttribArray(4);
- try vbobind.enableAttribArray(5);
- try vbobind.enableAttribArray(6);
- try vbobind.enableAttribArray(7);
- try vbobind.attributeDivisor(0, 1);
- try vbobind.attributeDivisor(1, 1);
- try vbobind.attributeDivisor(2, 1);
- try vbobind.attributeDivisor(3, 1);
- try vbobind.attributeDivisor(4, 1);
- try vbobind.attributeDivisor(5, 1);
- try vbobind.attributeDivisor(6, 1);
- try vbobind.attributeDivisor(7, 1);
-
- return .{
- .program = program,
- .vao = vao,
- .ebo = ebo,
- .vbo = vbo,
- };
-}
-
-pub fn bind(self: CellProgram) !Binding {
- const program = try self.program.use();
- errdefer program.unbind();
-
- const vao = try self.vao.bind();
- errdefer vao.unbind();
-
- const ebo = try self.ebo.bind(.element_array);
- errdefer ebo.unbind();
-
- const vbo = try self.vbo.bind(.array);
- errdefer vbo.unbind();
-
- return .{
- .program = program,
- .vao = vao,
- .ebo = ebo,
- .vbo = vbo,
- };
-}
-
-pub fn deinit(self: CellProgram) void {
- self.vbo.destroy();
- self.ebo.destroy();
- self.vao.destroy();
- self.program.destroy();
-}
-
-pub const Binding = struct {
- program: gl.Program.Binding,
- vao: gl.VertexArray.Binding,
- ebo: gl.Buffer.Binding,
- vbo: gl.Buffer.Binding,
-
- pub fn unbind(self: Binding) void {
- self.vbo.unbind();
- self.ebo.unbind();
- self.vao.unbind();
- self.program.unbind();
- }
-};
diff --git a/src/renderer/opengl/Frame.zig b/src/renderer/opengl/Frame.zig
new file mode 100644
index 000000000..4c23fe106
--- /dev/null
+++ b/src/renderer/opengl/Frame.zig
@@ -0,0 +1,75 @@
+//! Wrapper for handling render passes.
+const Self = @This();
+
+const std = @import("std");
+const Allocator = std.mem.Allocator;
+const assert = std.debug.assert;
+const builtin = @import("builtin");
+const gl = @import("opengl");
+
+const Renderer = @import("../generic.zig").Renderer(OpenGL);
+const OpenGL = @import("../OpenGL.zig");
+const Target = @import("Target.zig");
+const Pipeline = @import("Pipeline.zig");
+const RenderPass = @import("RenderPass.zig");
+const Buffer = @import("buffer.zig").Buffer;
+
+const Health = @import("../../renderer.zig").Health;
+
+const log = std.log.scoped(.opengl);
+
+/// Options for beginning a frame.
+pub const Options = struct {};
+
+renderer: *Renderer,
+target: *Target,
+
+/// Begin encoding a frame.
+pub fn begin(
+ opts: Options,
+ /// Once the frame has been completed, the `frameCompleted` method
+ /// on the renderer is called with the health status of the frame.
+ renderer: *Renderer,
+ /// The target is presented via the provided renderer's API when completed.
+ target: *Target,
+) !Self {
+ _ = opts;
+
+ return .{
+ .renderer = renderer,
+ .target = target,
+ };
+}
+
+/// Add a render pass to this frame with the provided attachments.
+/// Returns a RenderPass which allows render steps to be added.
+pub inline fn renderPass(
+ self: *const Self,
+ attachments: []const RenderPass.Options.Attachment,
+) RenderPass {
+ _ = self;
+ return RenderPass.begin(.{ .attachments = attachments });
+}
+
+/// Complete this frame and present the target.
+///
+/// If `sync` is true, this will block until the frame is presented.
+///
+/// NOTE: For OpenGL, `sync` is ignored and we always block.
+pub fn complete(self: *const Self, sync: bool) void {
+ _ = sync;
+ gl.finish();
+
+ // If there are any GL errors, consider the frame unhealthy.
+ const health: Health = if (gl.errors.getError()) .healthy else |_| .unhealthy;
+
+ // If the frame is healthy, present it.
+ if (health == .healthy) {
+ self.renderer.api.present(self.target.*) catch |err| {
+ log.err("Failed to present render target: err={}", .{err});
+ };
+ }
+
+ // Report the health to the renderer.
+ self.renderer.frameCompleted(health);
+}
diff --git a/src/renderer/opengl/ImageProgram.zig b/src/renderer/opengl/ImageProgram.zig
deleted file mode 100644
index ff6794085..000000000
--- a/src/renderer/opengl/ImageProgram.zig
+++ /dev/null
@@ -1,134 +0,0 @@
-/// The OpenGL program for rendering terminal cells.
-const ImageProgram = @This();
-
-const std = @import("std");
-const gl = @import("opengl");
-
-program: gl.Program,
-vao: gl.VertexArray,
-ebo: gl.Buffer,
-vbo: gl.Buffer,
-
-pub const Input = extern struct {
- /// vec2 grid_coord
- grid_col: i32,
- grid_row: i32,
-
- /// vec2 cell_offset
- cell_offset_x: u32 = 0,
- cell_offset_y: u32 = 0,
-
- /// vec4 source_rect
- source_x: u32 = 0,
- source_y: u32 = 0,
- source_width: u32 = 0,
- source_height: u32 = 0,
-
- /// vec2 dest_size
- dest_width: u32 = 0,
- dest_height: u32 = 0,
-};
-
-pub fn init() !ImageProgram {
- // Load and compile our shaders.
- const program = try gl.Program.createVF(
- @embedFile("../shaders/image.v.glsl"),
- @embedFile("../shaders/image.f.glsl"),
- );
- errdefer program.destroy();
-
- // Set our program uniforms
- const pbind = try program.use();
- defer pbind.unbind();
-
- // Set all of our texture indexes
- try program.setUniform("image", 0);
-
- // Setup our VAO
- const vao = try gl.VertexArray.create();
- errdefer vao.destroy();
- const vaobind = try vao.bind();
- defer vaobind.unbind();
-
- // Element buffer (EBO)
- const ebo = try gl.Buffer.create();
- errdefer ebo.destroy();
- var ebobind = try ebo.bind(.element_array);
- defer ebobind.unbind();
- try ebobind.setData([6]u8{
- 0, 1, 3, // Top-left triangle
- 1, 2, 3, // Bottom-right triangle
- }, .static_draw);
-
- // Vertex buffer (VBO)
- const vbo = try gl.Buffer.create();
- errdefer vbo.destroy();
- var vbobind = try vbo.bind(.array);
- defer vbobind.unbind();
- var offset: usize = 0;
- try vbobind.attributeAdvanced(0, 2, gl.c.GL_INT, false, @sizeOf(Input), offset);
- offset += 2 * @sizeOf(i32);
- try vbobind.attributeAdvanced(1, 2, gl.c.GL_UNSIGNED_INT, false, @sizeOf(Input), offset);
- offset += 2 * @sizeOf(u32);
- try vbobind.attributeAdvanced(2, 4, gl.c.GL_UNSIGNED_INT, false, @sizeOf(Input), offset);
- offset += 4 * @sizeOf(u32);
- try vbobind.attributeAdvanced(3, 2, gl.c.GL_UNSIGNED_INT, false, @sizeOf(Input), offset);
- offset += 2 * @sizeOf(u32);
- try vbobind.enableAttribArray(0);
- try vbobind.enableAttribArray(1);
- try vbobind.enableAttribArray(2);
- try vbobind.enableAttribArray(3);
- try vbobind.attributeDivisor(0, 1);
- try vbobind.attributeDivisor(1, 1);
- try vbobind.attributeDivisor(2, 1);
- try vbobind.attributeDivisor(3, 1);
-
- return .{
- .program = program,
- .vao = vao,
- .ebo = ebo,
- .vbo = vbo,
- };
-}
-
-pub fn bind(self: ImageProgram) !Binding {
- const program = try self.program.use();
- errdefer program.unbind();
-
- const vao = try self.vao.bind();
- errdefer vao.unbind();
-
- const ebo = try self.ebo.bind(.element_array);
- errdefer ebo.unbind();
-
- const vbo = try self.vbo.bind(.array);
- errdefer vbo.unbind();
-
- return .{
- .program = program,
- .vao = vao,
- .ebo = ebo,
- .vbo = vbo,
- };
-}
-
-pub fn deinit(self: ImageProgram) void {
- self.vbo.destroy();
- self.ebo.destroy();
- self.vao.destroy();
- self.program.destroy();
-}
-
-pub const Binding = struct {
- program: gl.Program.Binding,
- vao: gl.VertexArray.Binding,
- ebo: gl.Buffer.Binding,
- vbo: gl.Buffer.Binding,
-
- pub fn unbind(self: Binding) void {
- self.vbo.unbind();
- self.ebo.unbind();
- self.vao.unbind();
- self.program.unbind();
- }
-};
diff --git a/src/renderer/opengl/Pipeline.zig b/src/renderer/opengl/Pipeline.zig
new file mode 100644
index 000000000..c3d414ff2
--- /dev/null
+++ b/src/renderer/opengl/Pipeline.zig
@@ -0,0 +1,170 @@
+//! Wrapper for handling render pipelines.
+const Self = @This();
+
+const std = @import("std");
+const Allocator = std.mem.Allocator;
+const assert = std.debug.assert;
+const builtin = @import("builtin");
+const gl = @import("opengl");
+
+const OpenGL = @import("../OpenGL.zig");
+const Texture = @import("Texture.zig");
+const Buffer = @import("buffer.zig").Buffer;
+
+const log = std.log.scoped(.opengl);
+
+/// Options for initializing a render pipeline.
+pub const Options = struct {
+ /// GLSL source of the vertex function
+ vertex_fn: [:0]const u8,
+ /// GLSL source of the fragment function
+ fragment_fn: [:0]const u8,
+
+ /// Vertex step function
+ step_fn: StepFunction = .per_vertex,
+
+ /// Whether to enable blending.
+ blending_enabled: bool = true,
+
+ pub const StepFunction = enum {
+ constant,
+ per_vertex,
+ per_instance,
+ };
+};
+
+program: gl.Program,
+
+fbo: gl.Framebuffer,
+
+vao: gl.VertexArray,
+
+stride: usize,
+
+blending_enabled: bool,
+
+pub fn init(comptime VertexAttributes: ?type, opts: Options) !Self {
+ // Load and compile our shaders.
+ const program = try gl.Program.createVF(
+ opts.vertex_fn,
+ opts.fragment_fn,
+ );
+ errdefer program.destroy();
+
+ const pbind = try program.use();
+ defer pbind.unbind();
+
+ const fbo = try gl.Framebuffer.create();
+ errdefer fbo.destroy();
+ const fbobind = try fbo.bind(.framebuffer);
+ defer fbobind.unbind();
+
+ const vao = try gl.VertexArray.create();
+ errdefer vao.destroy();
+ const vaobind = try vao.bind();
+ defer vaobind.unbind();
+
+ if (VertexAttributes) |VA| try autoAttribute(VA, vaobind, opts.step_fn);
+
+ return .{
+ .program = program,
+ .fbo = fbo,
+ .vao = vao,
+ .stride = if (VertexAttributes) |VA| @sizeOf(VA) else 0,
+ .blending_enabled = opts.blending_enabled,
+ };
+}
+
+pub fn deinit(self: *const Self) void {
+ self.program.destroy();
+}
+
+fn autoAttribute(
+ T: type,
+ vaobind: gl.VertexArray.Binding,
+ step_fn: Options.StepFunction,
+) !void {
+ const divisor: gl.c.GLuint = switch (step_fn) {
+ .per_vertex => 0,
+ .per_instance => 1,
+ .constant => std.math.maxInt(gl.c.GLuint),
+ };
+
+ inline for (@typeInfo(T).@"struct".fields, 0..) |field, i| {
+ try vaobind.enableAttribArray(i);
+ try vaobind.attributeBinding(i, 0);
+ try vaobind.bindingDivisor(i, divisor);
+
+ 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,
+ };
+
+ const size, const IT = switch (@typeInfo(FT)) {
+ .array => |a| .{ a.len, a.child },
+ else => .{ 1, FT },
+ };
+
+ try switch (IT) {
+ u8 => vaobind.attributeIFormat(
+ i,
+ size,
+ gl.c.GL_UNSIGNED_BYTE,
+ offset,
+ ),
+ u16 => vaobind.attributeIFormat(
+ i,
+ size,
+ gl.c.GL_UNSIGNED_SHORT,
+ offset,
+ ),
+ u32 => vaobind.attributeIFormat(
+ i,
+ size,
+ gl.c.GL_UNSIGNED_INT,
+ offset,
+ ),
+ i8 => vaobind.attributeIFormat(
+ i,
+ size,
+ gl.c.GL_BYTE,
+ offset,
+ ),
+ i16 => vaobind.attributeIFormat(
+ i,
+ size,
+ gl.c.GL_SHORT,
+ offset,
+ ),
+ i32 => vaobind.attributeIFormat(
+ i,
+ size,
+ gl.c.GL_INT,
+ offset,
+ ),
+ f16 => vaobind.attributeFormat(
+ i,
+ size,
+ gl.c.GL_HALF_FLOAT,
+ false,
+ offset,
+ ),
+ f32 => vaobind.attributeFormat(
+ i,
+ size,
+ gl.c.GL_FLOAT,
+ false,
+ offset,
+ ),
+ f64 => vaobind.attributeLFormat(
+ i,
+ size,
+ offset,
+ ),
+ else => unreachable,
+ };
+ }
+}
diff --git a/src/renderer/opengl/RenderPass.zig b/src/renderer/opengl/RenderPass.zig
new file mode 100644
index 000000000..0f5bd89e7
--- /dev/null
+++ b/src/renderer/opengl/RenderPass.zig
@@ -0,0 +1,141 @@
+//! Wrapper for handling render passes.
+const Self = @This();
+
+const std = @import("std");
+const Allocator = std.mem.Allocator;
+const assert = std.debug.assert;
+const builtin = @import("builtin");
+const gl = @import("opengl");
+
+const OpenGL = @import("../OpenGL.zig");
+const Target = @import("Target.zig");
+const Texture = @import("Texture.zig");
+const Pipeline = @import("Pipeline.zig");
+const RenderPass = @import("RenderPass.zig");
+const Buffer = @import("buffer.zig").Buffer;
+
+/// Options for beginning a render pass.
+pub const Options = struct {
+ /// Color attachments for this render pass.
+ attachments: []const Attachment,
+
+ /// Describes a color attachment.
+ pub const Attachment = struct {
+ target: union(enum) {
+ texture: Texture,
+ target: Target,
+ },
+ clear_color: ?[4]f32 = null,
+ };
+};
+
+/// Describes a step in a render pass.
+pub const Step = struct {
+ pipeline: Pipeline,
+ uniforms: ?gl.Buffer = null,
+ buffers: []const ?gl.Buffer = &.{},
+ textures: []const ?Texture = &.{},
+ draw: Draw,
+
+ /// Describes the draw call for this step.
+ pub const Draw = struct {
+ type: gl.Primitive,
+ vertex_count: usize,
+ instance_count: usize = 1,
+ };
+};
+
+attachments: []const Options.Attachment,
+
+step_number: usize = 0,
+
+/// Begin a render pass.
+pub fn begin(
+ opts: Options,
+) Self {
+ return .{
+ .attachments = opts.attachments,
+ };
+}
+
+/// Add a step to this render pass.
+///
+/// TODO: Errors are silently ignored in this function, maybe they shouldn't be?
+pub fn step(self: *Self, s: Step) void {
+ if (s.draw.instance_count == 0) return;
+
+ const pbind = s.pipeline.program.use() catch return;
+ defer pbind.unbind();
+
+ const vaobind = s.pipeline.vao.bind() catch return;
+ defer vaobind.unbind();
+
+ const fbobind = switch (self.attachments[0].target) {
+ .target => |t| t.framebuffer.bind(.framebuffer) catch return,
+ .texture => |t| bind: {
+ const fbobind = s.pipeline.fbo.bind(.framebuffer) catch return;
+ fbobind.texture2D(.color0, t.target, t.texture, 0) catch {
+ fbobind.unbind();
+ return;
+ };
+ break :bind fbobind;
+ },
+ };
+ defer fbobind.unbind();
+
+ defer self.step_number += 1;
+
+ // If we have a clear color and this is the
+ // first step in the pass, go ahead and clear.
+ if (self.step_number == 0) if (self.attachments[0].clear_color) |c| {
+ gl.clearColor(c[0], c[1], c[2], c[3]);
+ gl.clear(gl.c.GL_COLOR_BUFFER_BIT);
+ };
+
+ // Bind the uniform buffer we bind at index 1 to align with Metal.
+ if (s.uniforms) |ubo| {
+ _ = ubo.bindBase(.uniform, 1) catch return;
+ }
+
+ // Bind relevant texture units.
+ for (s.textures, 0..) |t, i| if (t) |tex| {
+ gl.Texture.active(@intCast(i)) catch return;
+ _ = tex.texture.bind(tex.target) catch return;
+ };
+
+ // Bind 0th buffer as the vertex buffer,
+ // and bind the rest as storage buffers.
+ if (s.buffers.len > 0) {
+ if (s.buffers[0]) |vbo| vaobind.bindVertexBuffer(
+ 0,
+ vbo.id,
+ 0,
+ @intCast(s.pipeline.stride),
+ ) catch return;
+
+ for (s.buffers[1..], 1..) |b, i| if (b) |buf| {
+ _ = buf.bindBase(.storage, @intCast(i)) catch return;
+ };
+ }
+
+ if (s.pipeline.blending_enabled) {
+ gl.enable(gl.c.GL_BLEND) catch return;
+ gl.blendFunc(gl.c.GL_ONE, gl.c.GL_ONE_MINUS_SRC_ALPHA) catch return;
+ } else {
+ gl.disable(gl.c.GL_BLEND) catch return;
+ }
+
+ gl.drawArraysInstanced(
+ s.draw.type,
+ 0,
+ @intCast(s.draw.vertex_count),
+ @intCast(s.draw.instance_count),
+ ) catch return;
+}
+
+/// Complete this render pass.
+/// This struct can no longer be used after calling this.
+pub fn complete(self: *const Self) void {
+ _ = self;
+ gl.flush();
+}
diff --git a/src/renderer/opengl/Target.zig b/src/renderer/opengl/Target.zig
new file mode 100644
index 000000000..1b3a13ed0
--- /dev/null
+++ b/src/renderer/opengl/Target.zig
@@ -0,0 +1,62 @@
+//! Represents a render target.
+//!
+//! In this case, an OpenGL renderbuffer-backed framebuffer.
+const Self = @This();
+
+const std = @import("std");
+const Allocator = std.mem.Allocator;
+const assert = std.debug.assert;
+const builtin = @import("builtin");
+const gl = @import("opengl");
+
+const log = std.log.scoped(.opengl);
+
+/// Options for initializing a Target
+pub const Options = struct {
+ /// Desired width
+ width: usize,
+ /// Desired height
+ height: usize,
+
+ /// Internal format for the renderbuffer.
+ internal_format: gl.Texture.InternalFormat,
+};
+
+/// The underlying `gl.Framebuffer` instance.
+framebuffer: gl.Framebuffer,
+
+/// The underlying `gl.Renderbuffer` instance.
+renderbuffer: gl.Renderbuffer,
+
+/// Current width of this target.
+width: usize,
+/// Current height of this target.
+height: usize,
+
+pub fn init(opts: Options) !Self {
+ const rbo = try gl.Renderbuffer.create();
+ const bound_rbo = try rbo.bind();
+ defer bound_rbo.unbind();
+ try bound_rbo.storage(
+ opts.internal_format,
+ @intCast(opts.width),
+ @intCast(opts.height),
+ );
+
+ const fbo = try gl.Framebuffer.create();
+ const bound_fbo = try fbo.bind(.framebuffer);
+ defer bound_fbo.unbind();
+ try bound_fbo.renderbuffer(.color0, rbo);
+
+ return .{
+ .framebuffer = fbo,
+ .renderbuffer = rbo,
+ .width = opts.width,
+ .height = opts.height,
+ };
+}
+
+pub fn deinit(self: *Self) void {
+ self.framebuffer.destroy();
+ self.renderbuffer.destroy();
+}
diff --git a/src/renderer/opengl/Texture.zig b/src/renderer/opengl/Texture.zig
new file mode 100644
index 000000000..9be2b7078
--- /dev/null
+++ b/src/renderer/opengl/Texture.zig
@@ -0,0 +1,102 @@
+//! Wrapper for handling textures.
+const Self = @This();
+
+const std = @import("std");
+const Allocator = std.mem.Allocator;
+const assert = std.debug.assert;
+const builtin = @import("builtin");
+const gl = @import("opengl");
+
+const OpenGL = @import("../OpenGL.zig");
+
+const log = std.log.scoped(.opengl);
+
+/// Options for initializing a texture.
+pub const Options = struct {
+ format: gl.Texture.Format,
+ internal_format: gl.Texture.InternalFormat,
+ target: gl.Texture.Target,
+};
+
+texture: gl.Texture,
+
+/// The width of this texture.
+width: usize,
+/// The height of this texture.
+height: usize,
+
+/// Format for this texture.
+format: gl.Texture.Format,
+
+/// Target for this texture.
+target: gl.Texture.Target,
+
+pub const Error = error{
+ /// An OpenGL API call failed.
+ OpenGLFailed,
+};
+
+/// Initialize a texture
+pub fn init(
+ opts: Options,
+ width: usize,
+ height: usize,
+ data: ?[]const u8,
+) Error!Self {
+ const tex = gl.Texture.create() catch return error.OpenGLFailed;
+ errdefer tex.destroy();
+ {
+ const texbind = tex.bind(opts.target) catch return error.OpenGLFailed;
+ defer texbind.unbind();
+ texbind.parameter(.WrapS, gl.c.GL_CLAMP_TO_EDGE) catch return error.OpenGLFailed;
+ texbind.parameter(.WrapT, gl.c.GL_CLAMP_TO_EDGE) catch return error.OpenGLFailed;
+ texbind.parameter(.MinFilter, gl.c.GL_LINEAR) catch return error.OpenGLFailed;
+ texbind.parameter(.MagFilter, gl.c.GL_LINEAR) catch return error.OpenGLFailed;
+ texbind.image2D(
+ 0,
+ opts.internal_format,
+ @intCast(width),
+ @intCast(height),
+ opts.format,
+ .UnsignedByte,
+ if (data) |d| @ptrCast(d.ptr) else null,
+ ) catch return error.OpenGLFailed;
+ }
+
+ return .{
+ .texture = tex,
+ .width = width,
+ .height = height,
+ .format = opts.format,
+ .target = opts.target,
+ };
+}
+
+pub fn deinit(self: Self) void {
+ self.texture.destroy();
+}
+
+/// Replace a region of the texture with the provided data.
+///
+/// Does NOT check the dimensions of the data to ensure correctness.
+pub fn replaceRegion(
+ self: Self,
+ x: usize,
+ y: usize,
+ width: usize,
+ height: usize,
+ data: []const u8,
+) Error!void {
+ const texbind = self.texture.bind(self.target) catch return error.OpenGLFailed;
+ defer texbind.unbind();
+ texbind.subImage2D(
+ 0,
+ @intCast(x),
+ @intCast(y),
+ @intCast(width),
+ @intCast(height),
+ self.format,
+ .UnsignedByte,
+ data.ptr,
+ ) catch return error.OpenGLFailed;
+}
diff --git a/src/renderer/opengl/buffer.zig b/src/renderer/opengl/buffer.zig
new file mode 100644
index 000000000..48b6f410e
--- /dev/null
+++ b/src/renderer/opengl/buffer.zig
@@ -0,0 +1,127 @@
+const std = @import("std");
+const Allocator = std.mem.Allocator;
+const assert = std.debug.assert;
+const gl = @import("opengl");
+
+const OpenGL = @import("../OpenGL.zig");
+
+const log = std.log.scoped(.opengl);
+
+/// Options for initializing a buffer.
+pub const Options = struct {
+ target: gl.Buffer.Target = .array,
+ usage: gl.Buffer.Usage = .dynamic_draw,
+};
+
+/// OpenGL data storage for a certain set of equal types. This is usually
+/// used for vertex buffers, etc. This helpful wrapper makes it easy to
+/// prealloc, shrink, grow, sync, buffers with OpenGL.
+pub fn Buffer(comptime T: type) type {
+ return struct {
+ const Self = @This();
+
+ /// Underlying `gl.Buffer` instance.
+ buffer: gl.Buffer,
+
+ /// Options this buffer was allocated with.
+ opts: Options,
+
+ /// Current allocated length of the data store.
+ /// Note this is the number of `T`s, not the size in bytes.
+ len: usize,
+
+ /// Initialize a buffer with the given length pre-allocated.
+ pub fn init(opts: Options, len: usize) !Self {
+ const buffer = try gl.Buffer.create();
+ errdefer buffer.destroy();
+
+ const binding = try buffer.bind(opts.target);
+ defer binding.unbind();
+
+ try binding.setDataNullManual(len * @sizeOf(T), opts.usage);
+
+ return .{
+ .buffer = buffer,
+ .opts = opts,
+ .len = len,
+ };
+ }
+
+ /// Init the buffer filled with the given data.
+ pub fn initFill(opts: Options, data: []const T) !Self {
+ const buffer = try gl.Buffer.create();
+ errdefer buffer.destroy();
+
+ const binding = try buffer.bind(opts.target);
+ defer binding.unbind();
+
+ try binding.setData(data, opts.usage);
+
+ return .{
+ .buffer = buffer,
+ .opts = opts,
+ .len = data.len * @sizeOf(T),
+ };
+ }
+
+ pub fn deinit(self: Self) void {
+ self.buffer.destroy();
+ }
+
+ /// Sync new contents to the buffer. The data is expected to be the
+ /// complete contents of the buffer. If the amount of data is larger
+ /// than the buffer length, the buffer will be reallocated.
+ ///
+ /// If the amount of data is smaller than the buffer length, the
+ /// remaining data in the buffer is left untouched.
+ pub fn sync(self: *Self, data: []const T) !void {
+ const binding = try self.buffer.bind(self.opts.target);
+ defer binding.unbind();
+
+ // If we need more space than our buffer has, we need to reallocate.
+ if (data.len > self.len) {
+ // Reallocate the buffer to hold double what we require.
+ self.len = data.len * 2;
+ try binding.setDataNullManual(
+ self.len * @sizeOf(T),
+ self.opts.usage,
+ );
+ }
+
+ // We can fit within the buffer so we can just replace bytes.
+ try binding.setSubData(0, data);
+ }
+
+ /// Like Buffer.sync but takes data from an array of ArrayLists,
+ /// rather than a single array. Returns the number of items synced.
+ pub fn syncFromArrayLists(self: *Self, lists: []const std.ArrayListUnmanaged(T)) !usize {
+ const binding = try self.buffer.bind(self.opts.target);
+ defer binding.unbind();
+
+ var total_len: usize = 0;
+ for (lists) |list| {
+ total_len += list.items.len;
+ }
+
+ // If we need more space than our buffer has, we need to reallocate.
+ if (total_len > self.len) {
+ // Reallocate the buffer to hold double what we require.
+ self.len = total_len * 2;
+ try binding.setDataNullManual(
+ self.len * @sizeOf(T),
+ self.opts.usage,
+ );
+ }
+
+ // We can fit within the buffer so we can just replace bytes.
+ var i: usize = 0;
+
+ for (lists) |list| {
+ try binding.setSubData(i, list.items);
+ i += list.items.len * @sizeOf(T);
+ }
+
+ return total_len;
+ }
+ };
+}
diff --git a/src/renderer/opengl/custom.zig b/src/renderer/opengl/custom.zig
deleted file mode 100644
index 859277ce5..000000000
--- a/src/renderer/opengl/custom.zig
+++ /dev/null
@@ -1,310 +0,0 @@
-const std = @import("std");
-const Allocator = std.mem.Allocator;
-const gl = @import("opengl");
-const Size = @import("../size.zig").Size;
-
-const log = std.log.scoped(.opengl_custom);
-
-/// The "INDEX" is the index into the global GL state and the
-/// "BINDING" is the binding location in the shader.
-const UNIFORM_INDEX: gl.c.GLuint = 0;
-const UNIFORM_BINDING: gl.c.GLuint = 0;
-
-/// Global uniforms for custom shaders.
-pub const Uniforms = extern struct {
- resolution: [3]f32 align(16) = .{ 0, 0, 0 },
- time: f32 align(4) = 1,
- time_delta: f32 align(4) = 1,
- frame_rate: f32 align(4) = 1,
- frame: i32 align(4) = 1,
- channel_time: [4][4]f32 align(16) = [1][4]f32{.{ 0, 0, 0, 0 }} ** 4,
- channel_resolution: [4][4]f32 align(16) = [1][4]f32{.{ 0, 0, 0, 0 }} ** 4,
- mouse: [4]f32 align(16) = .{ 0, 0, 0, 0 },
- date: [4]f32 align(16) = .{ 0, 0, 0, 0 },
- sample_rate: f32 align(4) = 1,
-};
-
-/// The state associated with custom shaders. This should only be initialized
-/// if there is at least one custom shader.
-///
-/// To use this, the main terminal shader should render to the framebuffer
-/// specified by "fbo". The resulting "fb_texture" will contain the color
-/// attachment. This is then used as the iChannel0 input to the custom
-/// shader.
-pub const State = struct {
- /// The uniform data
- uniforms: Uniforms,
-
- /// The OpenGL buffers
- fbo: gl.Framebuffer,
- ubo: gl.Buffer,
- vao: gl.VertexArray,
- ebo: gl.Buffer,
- fb_texture: gl.Texture,
-
- /// The set of programs for the custom shaders.
- programs: []const Program,
-
- /// The first time a frame was drawn. This is used to update
- /// the time uniform.
- first_frame_time: std.time.Instant,
-
- /// The last time a frame was drawn. This is used to update
- /// the time uniform.
- last_frame_time: std.time.Instant,
-
- pub fn init(
- alloc: Allocator,
- srcs: []const [:0]const u8,
- ) !State {
- if (srcs.len == 0) return error.OneCustomShaderRequired;
-
- // Create our programs
- var programs = std.ArrayList(Program).init(alloc);
- defer programs.deinit();
- errdefer for (programs.items) |p| p.deinit();
- for (srcs) |src| {
- try programs.append(try Program.init(src));
- }
-
- // Create the texture for the framebuffer
- const fb_tex = try gl.Texture.create();
- errdefer fb_tex.destroy();
- {
- const texbind = try fb_tex.bind(.@"2D");
- try texbind.parameter(.WrapS, gl.c.GL_CLAMP_TO_EDGE);
- try texbind.parameter(.WrapT, gl.c.GL_CLAMP_TO_EDGE);
- try texbind.parameter(.MinFilter, gl.c.GL_LINEAR);
- try texbind.parameter(.MagFilter, gl.c.GL_LINEAR);
- try texbind.image2D(
- 0,
- .rgb,
- 1,
- 1,
- 0,
- .rgb,
- .UnsignedByte,
- null,
- );
- }
-
- // Create our framebuffer for rendering off screen.
- // The shader prior to custom shaders should use this
- // framebuffer.
- const fbo = try gl.Framebuffer.create();
- errdefer fbo.destroy();
- const fbbind = try fbo.bind(.framebuffer);
- defer fbbind.unbind();
- try fbbind.texture2D(.color0, .@"2D", fb_tex, 0);
- const fbstatus = fbbind.checkStatus();
- if (fbstatus != .complete) {
- log.warn(
- "framebuffer is not complete state={}",
- .{fbstatus},
- );
- return error.InvalidFramebuffer;
- }
-
- // Create our uniform buffer that is shared across all
- // custom shaders
- const ubo = try gl.Buffer.create();
- errdefer ubo.destroy();
- {
- var ubobind = try ubo.bind(.uniform);
- defer ubobind.unbind();
- try ubobind.setDataNull(Uniforms, .static_draw);
- }
-
- // Setup our VAO for the custom shader.
- const vao = try gl.VertexArray.create();
- errdefer vao.destroy();
- const vaobind = try vao.bind();
- defer vaobind.unbind();
-
- // Element buffer (EBO)
- const ebo = try gl.Buffer.create();
- errdefer ebo.destroy();
- var ebobind = try ebo.bind(.element_array);
- defer ebobind.unbind();
- try ebobind.setData([6]u8{
- 0, 1, 3, // Top-left triangle
- 1, 2, 3, // Bottom-right triangle
- }, .static_draw);
-
- return .{
- .programs = try programs.toOwnedSlice(),
- .uniforms = .{},
- .fbo = fbo,
- .ubo = ubo,
- .vao = vao,
- .ebo = ebo,
- .fb_texture = fb_tex,
- .first_frame_time = try std.time.Instant.now(),
- .last_frame_time = try std.time.Instant.now(),
- };
- }
-
- pub fn deinit(self: *const State, alloc: Allocator) void {
- for (self.programs) |p| p.deinit();
- alloc.free(self.programs);
- self.ubo.destroy();
- self.ebo.destroy();
- self.vao.destroy();
- self.fb_texture.destroy();
- self.fbo.destroy();
- }
-
- pub fn setScreenSize(self: *State, size: Size) !void {
- // Update our uniforms
- self.uniforms.resolution = .{
- @floatFromInt(size.screen.width),
- @floatFromInt(size.screen.height),
- 1,
- };
- try self.syncUniforms();
-
- // Update our texture
- const texbind = try self.fb_texture.bind(.@"2D");
- try texbind.image2D(
- 0,
- .rgb,
- @intCast(size.screen.width),
- @intCast(size.screen.height),
- 0,
- .rgb,
- .UnsignedByte,
- null,
- );
- }
-
- /// Call this prior to drawing a frame to update the time
- /// and synchronize the uniforms. This synchronizes uniforms
- /// so you should make changes to uniforms prior to calling
- /// this.
- pub fn newFrame(self: *State) !void {
- // Update our frame time
- const now = std.time.Instant.now() catch self.first_frame_time;
- const since_ns: f32 = @floatFromInt(now.since(self.first_frame_time));
- const delta_ns: f32 = @floatFromInt(now.since(self.last_frame_time));
- self.uniforms.time = since_ns / std.time.ns_per_s;
- self.uniforms.time_delta = delta_ns / std.time.ns_per_s;
- self.last_frame_time = now;
-
- // Sync our uniform changes
- try self.syncUniforms();
- }
-
- fn syncUniforms(self: *State) !void {
- var ubobind = try self.ubo.bind(.uniform);
- defer ubobind.unbind();
- try ubobind.setData(self.uniforms, .static_draw);
- }
-
- /// Call this to bind all the necessary OpenGL resources for
- /// all custom shaders. Each individual shader needs to be bound
- /// one at a time too.
- pub fn bind(self: *const State) !Binding {
- // Move our uniform buffer into proper global index. Note that
- // in theory we can do this globally once and never worry about
- // it again. I don't think we're high-performance enough at all
- // to worry about that and this makes it so you can just move
- // around CustomProgram usage without worrying about clobbering
- // the global state.
- try self.ubo.bindBase(.uniform, UNIFORM_INDEX);
-
- // Bind our texture that is shared amongst all
- try gl.Texture.active(gl.c.GL_TEXTURE0);
- var texbind = try self.fb_texture.bind(.@"2D");
- errdefer texbind.unbind();
-
- const vao = try self.vao.bind();
- errdefer vao.unbind();
-
- const ebo = try self.ebo.bind(.element_array);
- errdefer ebo.unbind();
-
- return .{
- .vao = vao,
- .ebo = ebo,
- .fb_texture = texbind,
- };
- }
-
- /// Copy the fbo's attached texture to the backbuffer.
- pub fn copyFramebuffer(self: *State) !void {
- const texbind = try self.fb_texture.bind(.@"2D");
- errdefer texbind.unbind();
- try texbind.copySubImage2D(
- 0,
- 0,
- 0,
- 0,
- 0,
- @intFromFloat(self.uniforms.resolution[0]),
- @intFromFloat(self.uniforms.resolution[1]),
- );
- }
-
- pub const Binding = struct {
- vao: gl.VertexArray.Binding,
- ebo: gl.Buffer.Binding,
- fb_texture: gl.Texture.Binding,
-
- pub fn unbind(self: Binding) void {
- self.ebo.unbind();
- self.vao.unbind();
- self.fb_texture.unbind();
- }
- };
-};
-
-/// A single OpenGL program (combined shaders) for custom shaders.
-pub const Program = struct {
- program: gl.Program,
-
- pub fn init(src: [:0]const u8) !Program {
- const program = try gl.Program.createVF(
- @embedFile("../shaders/custom.v.glsl"),
- src,
- );
- errdefer program.destroy();
-
- // Map our uniform buffer to the global GL state
- try program.uniformBlockBinding(UNIFORM_INDEX, UNIFORM_BINDING);
-
- return .{ .program = program };
- }
-
- pub fn deinit(self: *const Program) void {
- self.program.destroy();
- }
-
- /// Bind the program for use. This should be called so that draw can
- /// be called.
- pub fn bind(self: *const Program) !Binding {
- const program = try self.program.use();
- errdefer program.unbind();
-
- return .{
- .program = program,
- };
- }
-
- pub const Binding = struct {
- program: gl.Program.Binding,
-
- pub fn unbind(self: Binding) void {
- self.program.unbind();
- }
-
- pub fn draw(self: Binding) !void {
- _ = self;
- try gl.drawElementsInstanced(
- gl.c.GL_TRIANGLES,
- 6,
- gl.c.GL_UNSIGNED_BYTE,
- 1,
- );
- }
- };
-};
diff --git a/src/renderer/opengl/image.zig b/src/renderer/opengl/image.zig
deleted file mode 100644
index 26cd90736..000000000
--- a/src/renderer/opengl/image.zig
+++ /dev/null
@@ -1,426 +0,0 @@
-const std = @import("std");
-const Allocator = std.mem.Allocator;
-const assert = std.debug.assert;
-const gl = @import("opengl");
-const wuffs = @import("wuffs");
-
-/// 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: gl.Texture,
-
- /// The image is uploaded but is scheduled to be unloaded.
- unload_pending: []u8,
- unload_ready: gl.Texture,
- unload_replace: struct { []u8, gl.Texture },
-
- pub const Replace = struct {
- texture: gl.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.destroy();
- },
-
- .replace_gray_alpha => |r| {
- alloc.free(r.pending.dataSlice(2));
- r.texture.destroy();
- },
-
- .replace_rgb => |r| {
- alloc.free(r.pending.dataSlice(3));
- r.texture.destroy();
- },
-
- .replace_rgba => |r| {
- alloc.free(r.pending.dataSlice(4));
- r.texture.destroy();
- },
-
- .unload_replace => |r| {
- alloc.free(r[0]);
- r[1].destroy();
- },
-
- .ready,
- .unload_ready,
- => |tex| tex.destroy(),
- }
- }
-
- /// 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: gl.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,
- ) !void {
- // 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 gl.Texture.create();
- errdefer tex.destroy();
-
- const texbind = try tex.bind(.@"2D");
- try texbind.parameter(.WrapS, gl.c.GL_CLAMP_TO_EDGE);
- try texbind.parameter(.WrapT, gl.c.GL_CLAMP_TO_EDGE);
- try texbind.parameter(.MinFilter, gl.c.GL_LINEAR);
- try texbind.parameter(.MagFilter, gl.c.GL_LINEAR);
- try texbind.image2D(
- 0,
- formats.internal,
- @intCast(p.width),
- @intCast(p.height),
- 0,
- formats.format,
- .UnsignedByte,
- p.data,
- );
-
- // 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
new file mode 100644
index 000000000..0b67eaff0
--- /dev/null
+++ b/src/renderer/opengl/shaders.zig
@@ -0,0 +1,375 @@
+const std = @import("std");
+const Allocator = std.mem.Allocator;
+const assert = std.debug.assert;
+const math = @import("../../math.zig");
+
+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 {
+ /// 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
+ /// against the output of the previous shader.
+ post_pipelines: []const Pipeline,
+
+ /// Set to true when deinited, if you try to deinit a defunct set
+ /// of shaders it will just be ignored, to prevent double-free.
+ defunct: bool = false,
+
+ /// Initialize our shader set.
+ ///
+ /// "post_shaders" is an optional list of postprocess shaders to run
+ /// against the final drawable texture. This is an array of shader source
+ /// code, not file paths.
+ pub fn init(
+ alloc: Allocator,
+ post_shaders: []const [:0]const u8,
+ ) !Shaders {
+ var pipelines: PipelineCollection = undefined;
+
+ var initialized_pipelines: usize = 0;
+
+ 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,
+ post_shaders,
+ ) catch |err| err: {
+ // If an error happens while building postprocess shaders we
+ // want to just not use any postprocess shaders since we don't
+ // want to block Ghostty from working.
+ log.warn("error initializing postprocess shaders err={}", .{err});
+ break :err &.{};
+ };
+ errdefer if (post_pipelines.len > 0) {
+ for (post_pipelines) |pipeline| pipeline.deinit();
+ alloc.free(post_pipelines);
+ };
+
+ return .{
+ .pipelines = pipelines,
+ .post_pipelines = post_pipelines,
+ };
+ }
+
+ pub fn deinit(self: *Shaders, alloc: Allocator) void {
+ if (self.defunct) return;
+ self.defunct = true;
+
+ // Release our primary shaders
+ inline for (pipeline_descs) |pipeline| {
+ @field(self.pipelines, pipeline[0]).deinit();
+ }
+
+ // Release our postprocess shaders
+ if (self.post_pipelines.len > 0) {
+ for (self.post_pipelines) |pipeline| {
+ pipeline.deinit();
+ }
+ alloc.free(self.post_pipelines);
+ }
+ }
+};
+
+/// 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),
+
+ /// Size of the grid in columns and rows.
+ grid_size: [2]u16 align(4),
+
+ /// The padding around the terminal grid in pixels. In order:
+ /// top, right, bottom, left.
+ grid_padding: [4]f32 align(16),
+
+ /// Bit mask defining which directions to
+ /// extend cell colors in to the padding.
+ /// Order, LSB first: left, right, up, down
+ padding_extend: PaddingExtend align(4),
+
+ /// The minimum contrast ratio for text. The contrast ratio is calculated
+ /// according to the WCAG 2.0 spec.
+ min_contrast: f32 align(4),
+
+ /// The cursor position and color.
+ cursor_pos: [2]u16 align(4),
+ cursor_color: [4]u8 align(4),
+
+ /// The background color for the whole surface.
+ bg_color: [4]u8 align(4),
+
+ /// Various booleans, in a packed struct for space efficiency.
+ bools: Bools align(4),
+
+ const Bools = packed struct(u32) {
+ /// Whether the cursor is 2 cells wide.
+ cursor_wide: bool,
+
+ /// Indicates that colors provided to the shader are already in
+ /// the P3 color space, so they don't need to be converted from
+ /// sRGB.
+ use_display_p3: bool,
+
+ /// Indicates that the color attachments for the shaders have
+ /// an `*_srgb` pixel format, which means the shaders need to
+ /// output linear RGB colors rather than gamma encoded colors,
+ /// since blending will be performed in linear space and then
+ /// Metal itself will re-encode the colors for storage.
+ use_linear_blending: bool,
+
+ /// Enables a weight correction step that makes text rendered
+ /// with linear alpha blending have a similar apparent weight
+ /// (thickness) to gamma-incorrect blending.
+ use_linear_correction: bool = false,
+
+ _padding: u28 = 0,
+ };
+
+ const PaddingExtend = packed struct(u32) {
+ left: bool = false,
+ right: bool = false,
+ up: bool = false,
+ down: bool = false,
+ _padding: u28 = 0,
+ };
+};
+
+/// 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(
+ alloc: Allocator,
+ shaders: []const [:0]const u8,
+) ![]const Pipeline {
+ // If we have no shaders, do nothing.
+ if (shaders.len == 0) return &.{};
+
+ // Keeps track of how many shaders we successfully wrote.
+ var i: usize = 0;
+
+ // Initialize our result set. If any error happens, we undo everything.
+ var pipelines = try alloc.alloc(Pipeline, shaders.len);
+ errdefer {
+ for (pipelines[0..i]) |pipeline| {
+ pipeline.deinit();
+ }
+ alloc.free(pipelines);
+ }
+
+ // Build each shader. Note we don't use "0.." to build our index
+ // because we need to keep track of our length to clean up above.
+ for (shaders) |source| {
+ pipelines[i] = try initPostPipeline(source);
+ i += 1;
+ }
+
+ return pipelines;
+}
+
+/// Initialize a single custom shader pipeline from shader source.
+fn initPostPipeline(data: [:0]const u8) !Pipeline {
+ return try Pipeline.init(null, .{
+ .vertex_fn = loadShaderCode("../shaders/glsl/full_screen.v.glsl"),
+ .fragment_fn = data,
+ });
+}
+
+/// Load shader code from the target path, processing `#include` directives.
+///
+/// Comptime only for now, this code is really sloppy and makes a bunch of
+/// assumptions about things being well formed and file names not containing
+/// quote marks. If we ever want to process `#include`s for custom shaders
+/// then we need to write something better than this for it.
+fn loadShaderCode(comptime path: []const u8) [:0]const u8 {
+ return comptime processIncludes(@embedFile(path), std.fs.path.dirname(path).?);
+}
+
+/// Used by loadShaderCode
+fn processIncludes(contents: [:0]const u8, basedir: []const u8) [:0]const u8 {
+ @setEvalBranchQuota(100_000);
+ var i: usize = 0;
+ while (i < contents.len) {
+ if (std.mem.startsWith(u8, contents[i..], "#include")) {
+ assert(std.mem.startsWith(u8, contents[i..], "#include \""));
+ const start = i + "#include \"".len;
+ const end = std.mem.indexOfScalarPos(u8, contents, start, '"').?;
+ return std.fmt.comptimePrint(
+ "{s}{s}{s}",
+ .{
+ contents[0..i],
+ @embedFile(basedir ++ "/" ++ contents[start..end]),
+ processIncludes(contents[end + 1 ..], basedir),
+ },
+ );
+ }
+ if (std.mem.indexOfPos(u8, contents, i, "\n#")) |j| {
+ i = (j + 1);
+ } else {
+ break;
+ }
+ }
+ return contents;
+}
diff --git a/src/renderer/shaders/cell.f.glsl b/src/renderer/shaders/cell.f.glsl
deleted file mode 100644
index f9c1ce2b1..000000000
--- a/src/renderer/shaders/cell.f.glsl
+++ /dev/null
@@ -1,53 +0,0 @@
-#version 330 core
-
-in vec2 glyph_tex_coords;
-flat in uint mode;
-
-// The color for this cell. If this is a background pass this is the
-// background color. Otherwise, this is the foreground color.
-flat in vec4 color;
-
-// The position of the cells top-left corner.
-flat in vec2 screen_cell_pos;
-
-// Position the fragment coordinate to the upper left
-layout(origin_upper_left) in vec4 gl_FragCoord;
-
-// Must declare this output for some versions of OpenGL.
-layout(location = 0) out vec4 out_FragColor;
-
-// Font texture
-uniform sampler2D text;
-uniform sampler2D text_color;
-
-// Dimensions of the cell
-uniform vec2 cell_size;
-
-// See vertex shader
-const uint MODE_BG = 1u;
-const uint MODE_FG = 2u;
-const uint MODE_FG_CONSTRAINED = 3u;
-const uint MODE_FG_COLOR = 7u;
-const uint MODE_FG_POWERLINE = 15u;
-
-void main() {
- float a;
-
- switch (mode) {
- case MODE_BG:
- out_FragColor = color;
- break;
-
- case MODE_FG:
- case MODE_FG_CONSTRAINED:
- case MODE_FG_POWERLINE:
- a = texture(text, glyph_tex_coords).r;
- vec3 premult = color.rgb * color.a;
- out_FragColor = vec4(premult.rgb*a, a);
- break;
-
- case MODE_FG_COLOR:
- out_FragColor = texture(text_color, glyph_tex_coords);
- break;
- }
-}
diff --git a/src/renderer/shaders/cell.v.glsl b/src/renderer/shaders/cell.v.glsl
deleted file mode 100644
index f37e69adc..000000000
--- a/src/renderer/shaders/cell.v.glsl
+++ /dev/null
@@ -1,258 +0,0 @@
-#version 330 core
-
-// These are the possible modes that "mode" can be set to. This is
-// used to multiplex multiple render modes into a single shader.
-//
-// NOTE: this must be kept in sync with the fragment shader
-const uint MODE_BG = 1u;
-const uint MODE_FG = 2u;
-const uint MODE_FG_CONSTRAINED = 3u;
-const uint MODE_FG_COLOR = 7u;
-const uint MODE_FG_POWERLINE = 15u;
-
-// The grid coordinates (x, y) where x < columns and y < rows
-layout (location = 0) in vec2 grid_coord;
-
-// Position of the glyph in the texture.
-layout (location = 1) in vec2 glyph_pos;
-
-// Width/height of the glyph
-layout (location = 2) in vec2 glyph_size;
-
-// Offset of the top-left corner of the glyph when rendered in a rect.
-layout (location = 3) in vec2 glyph_offset;
-
-// The color for this cell in RGBA (0 to 1.0). Background or foreground
-// depends on mode.
-layout (location = 4) in vec4 color_in;
-
-// Only set for MODE_FG, this is the background color of the FG text.
-// This is used to detect minimal contrast for the text.
-layout (location = 5) in vec4 bg_color_in;
-
-// The mode of this shader. The mode determines what fields are used,
-// what the output will be, etc. This shader is capable of executing in
-// multiple "modes" so that we can share some logic and so that we can draw
-// the entire terminal grid in a single GPU pass.
-layout (location = 6) in uint mode_in;
-
-// The width in cells of this item.
-layout (location = 7) in uint grid_width;
-
-// The background or foreground color for the fragment, depending on
-// whether this is a background or foreground pass.
-flat out vec4 color;
-
-// The x/y coordinate for the glyph representing the font.
-out vec2 glyph_tex_coords;
-
-// The position of the cell top-left corner in screen cords. z and w
-// are width and height.
-flat out vec2 screen_cell_pos;
-
-// Pass the mode forward to the fragment shader.
-flat out uint mode;
-
-uniform sampler2D text;
-uniform sampler2D text_color;
-uniform vec2 cell_size;
-uniform vec2 grid_size;
-uniform vec4 grid_padding;
-uniform bool padding_vertical_top;
-uniform bool padding_vertical_bottom;
-uniform mat4 projection;
-uniform float min_contrast;
-
-/********************************************************************
- * Modes
- *
- *-------------------------------------------------------------------
- * MODE_BG
- *
- * In MODE_BG, this shader renders only the background color for the
- * cell. This is a simple mode where we generate a simple rectangle
- * made up of 4 vertices and then it is filled. In this mode, the output
- * "color" is the fill color for the bg.
- *
- *-------------------------------------------------------------------
- * MODE_FG
- *
- * In MODE_FG, the shader renders the glyph onto this cell and utilizes
- * the glyph texture "text". In this mode, the output "color" is the
- * fg color to use for the glyph.
- *
- */
-
-//-------------------------------------------------------------------
-// Color Functions
-//-------------------------------------------------------------------
-
-// https://www.w3.org/TR/2008/REC-WCAG20-20081211/#relativeluminancedef
-float luminance_component(float c) {
- if (c <= 0.03928) {
- return c / 12.92;
- } else {
- return pow((c + 0.055) / 1.055, 2.4);
- }
-}
-
-float relative_luminance(vec3 color) {
- vec3 color_adjusted = vec3(
- luminance_component(color.r),
- luminance_component(color.g),
- luminance_component(color.b)
- );
-
- vec3 weights = vec3(0.2126, 0.7152, 0.0722);
- return dot(color_adjusted, weights);
-}
-
-// https://www.w3.org/TR/2008/REC-WCAG20-20081211/#contrast-ratiodef
-float contrast_ratio(vec3 color1, vec3 color2) {
- float luminance1 = relative_luminance(color1) + 0.05;
- float luminance2 = relative_luminance(color2) + 0.05;
- return max(luminance1, luminance2) / min(luminance1, luminance2);
-}
-
-// Return the fg if the contrast ratio is greater than min, otherwise
-// return a color that satisfies the contrast ratio. Currently, the color
-// is always white or black, whichever has the highest contrast ratio.
-vec4 contrasted_color(float min_ratio, vec4 fg, vec4 bg) {
- vec3 fg_premult = fg.rgb * fg.a;
- vec3 bg_premult = bg.rgb * bg.a;
- float ratio = contrast_ratio(fg_premult, bg_premult);
- if (ratio < min_ratio) {
- float white_ratio = contrast_ratio(vec3(1.0, 1.0, 1.0), bg_premult);
- float black_ratio = contrast_ratio(vec3(0.0, 0.0, 0.0), bg_premult);
- if (white_ratio > black_ratio) {
- return vec4(1.0, 1.0, 1.0, fg.a);
- } else {
- return vec4(0.0, 0.0, 0.0, fg.a);
- }
- }
-
- return fg;
-}
-
-//-------------------------------------------------------------------
-// Main
-//-------------------------------------------------------------------
-
-void main() {
- // We always forward our mode unmasked because the fragment
- // shader doesn't use any of the masks.
- mode = mode_in;
-
- // Top-left cell coordinates converted to world space
- // Example: (1,0) with a 30 wide cell is converted to (30,0)
- vec2 cell_pos = cell_size * grid_coord;
-
- // Our Z value. For now we just use grid_z directly but we pull it
- // out here so the variable name is more uniform to our cell_pos and
- // in case we want to do any other math later.
- float cell_z = 0.0;
-
- // Turn the cell position into a vertex point depending on the
- // gl_VertexID. Since we use instanced drawing, we have 4 vertices
- // for each corner of the cell. We can use gl_VertexID to determine
- // which one we're looking at. Using this, we can use 1 or 0 to keep
- // or discard the value for the vertex.
- //
- // 0 = top-right
- // 1 = bot-right
- // 2 = bot-left
- // 3 = top-left
- vec2 position;
- position.x = (gl_VertexID == 0 || gl_VertexID == 1) ? 1. : 0.;
- position.y = (gl_VertexID == 0 || gl_VertexID == 3) ? 0. : 1.;
-
- // Scaled for wide chars
- vec2 cell_size_scaled = cell_size;
- cell_size_scaled.x = cell_size_scaled.x * grid_width;
-
- switch (mode) {
- case MODE_BG:
- // If we're at the edge of the grid, we add our padding to the background
- // to extend it. Note: grid_padding is top/right/bottom/left.
- if (grid_coord.y == 0 && padding_vertical_top) {
- cell_pos.y -= grid_padding.r;
- cell_size_scaled.y += grid_padding.r;
- } else if (grid_coord.y == grid_size.y - 1 && padding_vertical_bottom) {
- cell_size_scaled.y += grid_padding.b;
- }
- if (grid_coord.x == 0) {
- cell_pos.x -= grid_padding.a;
- cell_size_scaled.x += grid_padding.a;
- } else if (grid_coord.x == grid_size.x - 1) {
- cell_size_scaled.x += grid_padding.g;
- }
-
- // Calculate the final position of our cell in world space.
- // We have to add our cell size since our vertices are offset
- // one cell up and to the left. (Do the math to verify yourself)
- cell_pos = cell_pos + cell_size_scaled * position;
-
- gl_Position = projection * vec4(cell_pos, cell_z, 1.0);
- color = color_in / 255.0;
- break;
-
- case MODE_FG:
- case MODE_FG_CONSTRAINED:
- case MODE_FG_COLOR:
- case MODE_FG_POWERLINE:
- vec2 glyph_offset_calc = glyph_offset;
-
- // The glyph_offset.y is the y bearing, a y value that when added
- // to the baseline is the offset (+y is up). Our grid goes down.
- // So we flip it with `cell_size.y - glyph_offset.y`.
- glyph_offset_calc.y = cell_size_scaled.y - glyph_offset_calc.y;
-
- // If this is a constrained mode, we need to constrain it!
- vec2 glyph_size_calc = glyph_size;
- if (mode == MODE_FG_CONSTRAINED) {
- if (glyph_size.x > cell_size_scaled.x) {
- float new_y = glyph_size.y * (cell_size_scaled.x / glyph_size.x);
- glyph_offset_calc.y = glyph_offset_calc.y + ((glyph_size.y - new_y) / 2);
- glyph_size_calc.y = new_y;
- glyph_size_calc.x = cell_size_scaled.x;
- }
- }
-
- // Calculate the final position of the cell.
- cell_pos = cell_pos + (glyph_size_calc * position) + glyph_offset_calc;
- gl_Position = projection * vec4(cell_pos, cell_z, 1.0);
-
- // We need to convert our texture position and size to normalized
- // device coordinates (0 to 1.0) by dividing by the size of the texture.
- ivec2 text_size;
- switch(mode) {
- case MODE_FG_CONSTRAINED:
- case MODE_FG_POWERLINE:
- case MODE_FG:
- text_size = textureSize(text, 0);
- break;
-
- case MODE_FG_COLOR:
- text_size = textureSize(text_color, 0);
- break;
- }
- vec2 glyph_tex_pos = glyph_pos / text_size;
- vec2 glyph_tex_size = glyph_size / text_size;
- glyph_tex_coords = glyph_tex_pos + glyph_tex_size * position;
-
- // 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
- // with the background.
- // We only apply this adjustment to "normal" text with MODE_FG,
- // since we want color glyphs to appear in their original color
- // and Powerline glyphs to be unaffected (else parts of the line would
- // have different colors as some parts are displayed via background colors).
- vec4 color_final = color_in / 255.0;
- if (min_contrast > 1.0 && mode == MODE_FG) {
- vec4 bg_color = bg_color_in / 255.0;
- color_final = contrasted_color(min_contrast, color_final, bg_color);
- }
- color = color_final;
- break;
- }
-}
diff --git a/src/renderer/shaders/custom.v.glsl b/src/renderer/shaders/custom.v.glsl
deleted file mode 100644
index 653e1800e..000000000
--- a/src/renderer/shaders/custom.v.glsl
+++ /dev/null
@@ -1,8 +0,0 @@
-#version 330 core
-
-void main(){
- vec2 position;
- position.x = (gl_VertexID == 0 || gl_VertexID == 1) ? -1. : 1.;
- position.y = (gl_VertexID == 0 || gl_VertexID == 3) ? 1. : -1.;
- gl_Position = vec4(position.xy, 0.0f, 1.0f);
-}
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
new file mode 100644
index 000000000..7ba6caaa6
--- /dev/null
+++ b/src/renderer/shaders/glsl/cell_bg.f.glsl
@@ -0,0 +1,61 @@
+#include "common.glsl"
+
+// Position the origin to the upper left
+layout(origin_upper_left, pixel_center_integer) in vec4 gl_FragCoord;
+
+// Must declare this output for some versions of OpenGL.
+layout(location = 0) out vec4 out_FragColor;
+
+layout(binding = 1, std430) readonly buffer bg_cells {
+ uint cells[];
+};
+
+vec4 cell_bg() {
+ uvec2 grid_size = unpack2u16(grid_size_packed_2u16);
+ ivec2 grid_pos = ivec2(floor((gl_FragCoord.xy - grid_padding.wx) / cell_size));
+ bool use_linear_blending = (bools & USE_LINEAR_BLENDING) != 0;
+
+ vec4 bg = vec4(0.0);
+
+ // Clamp x position, extends edge bg colors in to padding on sides.
+ if (grid_pos.x < 0) {
+ if ((padding_extend & EXTEND_LEFT) != 0) {
+ grid_pos.x = 0;
+ } else {
+ return bg;
+ }
+ } else if (grid_pos.x > grid_size.x - 1) {
+ if ((padding_extend & EXTEND_RIGHT) != 0) {
+ grid_pos.x = int(grid_size.x) - 1;
+ } else {
+ return bg;
+ }
+ }
+
+ // Clamp y position if we should extend, otherwise discard if out of bounds.
+ if (grid_pos.y < 0) {
+ if ((padding_extend & EXTEND_UP) != 0) {
+ grid_pos.y = 0;
+ } else {
+ return bg;
+ }
+ } else if (grid_pos.y > grid_size.y - 1) {
+ if ((padding_extend & EXTEND_DOWN) != 0) {
+ grid_pos.y = int(grid_size.y) - 1;
+ } else {
+ return bg;
+ }
+ }
+
+ // Load the color for the cell.
+ vec4 cell_color = load_color(
+ unpack4u8(cells[grid_pos.y * grid_size.x + grid_pos.x]),
+ use_linear_blending
+ );
+
+ return cell_color;
+}
+
+void main() {
+ out_FragColor = cell_bg();
+}
diff --git a/src/renderer/shaders/glsl/cell_text.f.glsl b/src/renderer/shaders/glsl/cell_text.f.glsl
new file mode 100644
index 000000000..fda6d8134
--- /dev/null
+++ b/src/renderer/shaders/glsl/cell_text.f.glsl
@@ -0,0 +1,109 @@
+#include "common.glsl"
+
+layout(binding = 0) uniform sampler2DRect atlas_grayscale;
+layout(binding = 1) uniform sampler2DRect atlas_color;
+
+in CellTextVertexOut {
+ flat uint mode;
+ flat vec4 color;
+ flat vec4 bg_color;
+ vec2 tex_coord;
+} in_data;
+
+// These are the possible modes that "mode" can be set to. This is
+// used to multiplex multiple render modes into a single shader.
+//
+// NOTE: this must be kept in sync with the fragment shader
+const uint MODE_TEXT = 1u;
+const uint MODE_TEXT_CONSTRAINED = 2u;
+const uint MODE_TEXT_COLOR = 3u;
+const uint MODE_TEXT_CURSOR = 4u;
+const uint MODE_TEXT_POWERLINE = 5u;
+
+// 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;
+ bool use_linear_correction = (bools & USE_LINEAR_CORRECTION) != 0;
+
+ switch (in_data.mode) {
+ default:
+ case MODE_TEXT_CURSOR:
+ case MODE_TEXT_CONSTRAINED:
+ case MODE_TEXT_POWERLINE:
+ case MODE_TEXT:
+ {
+ // Our input color is always linear.
+ vec4 color = in_data.color;
+
+ // If we're not doing linear blending, then we need to
+ // re-apply the gamma encoding to our color manually.
+ //
+ // Since the alpha is premultiplied, we need to divide
+ // it out before unlinearizing and re-multiply it after.
+ if (!use_linear_blending) {
+ color.rgb /= vec3(color.a);
+ color = unlinearize(color);
+ color.rgb *= vec3(color.a);
+ }
+
+ // Fetch our alpha mask for this pixel.
+ float a = texture(atlas_grayscale, in_data.tex_coord).r;
+
+ // Linear blending weight correction corrects the alpha value to
+ // produce blending results which match gamma-incorrect blending.
+ if (use_linear_correction) {
+ // Short explanation of how this works:
+ //
+ // We get the luminances of the foreground and background colors,
+ // and then unlinearize them and perform blending on them. This
+ // gives us our desired luminance, which we derive our new alpha
+ // value from by mapping the range [bg_l, fg_l] to [0, 1], since
+ // our final blend will be a linear interpolation from bg to fg.
+ //
+ // This yields virtually identical results for grayscale blending,
+ // and very similar but non-identical results for color blending.
+ vec4 bg = in_data.bg_color;
+ float fg_l = luminance(color.rgb);
+ float bg_l = luminance(bg.rgb);
+ // To avoid numbers going haywire, we don't apply correction
+ // when the bg and fg luminances are within 0.001 of each other.
+ if (abs(fg_l - bg_l) > 0.001) {
+ float blend_l = linearize(unlinearize(fg_l) * a + unlinearize(bg_l) * (1.0 - a));
+ a = clamp((blend_l - bg_l) / (fg_l - bg_l), 0.0, 1.0);
+ }
+ }
+
+ // Multiply our whole color by the alpha mask.
+ // Since we use premultiplied alpha, this is
+ // the correct way to apply the mask.
+ color *= a;
+
+ out_FragColor = color;
+ return;
+ }
+
+ case MODE_TEXT_COLOR:
+ {
+ // For now, we assume that color glyphs
+ // are already premultiplied linear colors.
+ vec4 color = texture(atlas_color, in_data.tex_coord);
+
+ // If we are doing linear blending, we can return this right away.
+ if (use_linear_blending) {
+ out_FragColor = color;
+ return;
+ }
+
+ // 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 = unlinearize(color);
+ color.rgb *= vec3(color.a);
+
+ out_FragColor = color;
+ return;
+ }
+ }
+}
diff --git a/src/renderer/shaders/glsl/cell_text.v.glsl b/src/renderer/shaders/glsl/cell_text.v.glsl
new file mode 100644
index 000000000..10965ddd2
--- /dev/null
+++ b/src/renderer/shaders/glsl/cell_text.v.glsl
@@ -0,0 +1,168 @@
+#include "common.glsl"
+
+// The position of the glyph in the texture (x, y)
+layout(location = 0) in uvec2 glyph_pos;
+
+// The size of the glyph in the texture (w, h)
+layout(location = 1) in uvec2 glyph_size;
+
+// The left and top bearings for the glyph (x, y)
+layout(location = 2) in ivec2 bearings;
+
+// The grid coordinates (x, y) where x < columns and y < rows
+layout(location = 3) in uvec2 grid_pos;
+
+// The color of the rendered text glyph.
+layout(location = 4) in uvec4 color;
+
+// The mode for this cell.
+layout(location = 5) in uint mode;
+
+// The width to constrain the glyph to, in cells, or 0 for no constraint.
+layout(location = 6) in uint constraint_width;
+
+// These are the possible modes that "mode" can be set to. This is
+// used to multiplex multiple render modes into a single shader.
+const uint MODE_TEXT = 1u;
+const uint MODE_TEXT_CONSTRAINED = 2u;
+const uint MODE_TEXT_COLOR = 3u;
+const uint MODE_TEXT_CURSOR = 4u;
+const uint MODE_TEXT_POWERLINE = 5u;
+
+out CellTextVertexOut {
+ flat uint mode;
+ flat vec4 color;
+ flat vec4 bg_color;
+ vec2 tex_coord;
+} out_data;
+
+layout(binding = 1, std430) readonly buffer bg_cells {
+ uint bg_colors[];
+};
+
+void main() {
+ uvec2 grid_size = unpack2u16(grid_size_packed_2u16);
+ uvec2 cursor_pos = unpack2u16(cursor_pos_packed_2u16);
+ bool cursor_wide = (bools & CURSOR_WIDE) != 0;
+ bool use_linear_blending = (bools & USE_LINEAR_BLENDING) != 0;
+
+ // Convert the grid x, y into world space x, y by accounting for cell size
+ vec2 cell_pos = cell_size * vec2(grid_pos);
+
+ int vid = gl_VertexID;
+
+ // We use a triangle strip with 4 vertices to render quads,
+ // so we determine which corner of the cell this vertex is in
+ // based on the vertex ID.
+ //
+ // 0 --> 1
+ // | .'|
+ // | / |
+ // | L |
+ // 2 --> 3
+ //
+ // 0 = top-left (0, 0)
+ // 1 = top-right (1, 0)
+ // 2 = bot-left (0, 1)
+ // 3 = bot-right (1, 1)
+ vec2 corner;
+ corner.x = float(vid == 1 || vid == 3);
+ corner.y = float(vid == 2 || vid == 3);
+
+ out_data.mode = mode;
+
+ // === Grid Cell ===
+ // +X
+ // 0,0--...->
+ // |
+ // . offset.x = bearings.x
+ // +Y. .|.
+ // . | |
+ // | cell_pos -> +-------+ _.
+ // v ._| |_. _|- offset.y = cell_size.y - bearings.y
+ // | | .###. | |
+ // | | #...# | |
+ // glyph_size.y -+ | ##### | |
+ // | | #.... | +- bearings.y
+ // |_| .#### | |
+ // | |_|
+ // +-------+
+ // |_._|
+ // |
+ // glyph_size.x
+ //
+ // In order to get the top left of the glyph, we compute an offset based on
+ // the bearings. The Y bearing is the distance from the bottom of the cell
+ // to the top of the glyph, so we subtract it from the cell height to get
+ // the y offset. The X bearing is the distance from the left of the cell
+ // to the left of the glyph, so it works as the x offset directly.
+
+ vec2 size = vec2(glyph_size);
+ vec2 offset = vec2(bearings);
+
+ offset.y = cell_size.y - offset.y;
+
+ // If we're constrained then we need to scale the glyph.
+ if (mode == MODE_TEXT_CONSTRAINED) {
+ float max_width = cell_size.x * constraint_width;
+ // If this glyph is wider than the constraint width,
+ // fit it to the width and remove its horizontal offset.
+ if (size.x > max_width) {
+ float new_y = size.y * (max_width / size.x);
+ offset.y += (size.y - new_y) / 2.0;
+ offset.x = 0.0;
+ size.y = new_y;
+ size.x = max_width;
+ } else if (max_width - size.x > offset.x) {
+ // However, if it does fit in the constraint width, make
+ // sure the offset is small enough to not push it over the
+ // right edge of the constraint width.
+ offset.x = max_width - size.x;
+ }
+ }
+
+ // Calculate the final position of the cell which uses our glyph size
+ // and glyph offset to create the correct bounding box for the glyph.
+ cell_pos = cell_pos + size * corner + offset;
+ gl_Position = projection_matrix * vec4(cell_pos.x, cell_pos.y, 0.0f, 1.0f);
+
+ // Calculate the texture coordinate in pixels. This is NOT normalized
+ // (between 0.0 and 1.0), and does not need to be, since the texture will
+ // be sampled with pixel coordinate mode.
+ out_data.tex_coord = vec2(glyph_pos) + vec2(glyph_size) * corner;
+
+ // Get our color. We always fetch a linearized version to
+ // make it easier to handle minimum contrast calculations.
+ out_data.color = load_color(color, true);
+ // Get the BG color
+ out_data.bg_color = load_color(
+ 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
+ // with the background.
+ // We only apply this adjustment to "normal" text with MODE_TEXT,
+ // since we want color glyphs to appear in their original color
+ // and Powerline glyphs to be unaffected (else parts of the line would
+ // have different colors as some parts are displayed via background colors).
+ if (min_contrast > 1.0f && mode == MODE_TEXT) {
+ // Ensure our minimum contrast
+ out_data.color = contrasted_color(min_contrast, out_data.color, out_data.bg_color);
+ }
+
+ // Check if current position is under cursor (including wide cursor)
+ bool is_cursor_pos = ((grid_pos.x == cursor_pos.x) || (cursor_wide && (grid_pos.x == (cursor_pos.x + 1)))) && (grid_pos.y == cursor_pos.y);
+
+ // If this cell is the cursor cell, then we need to change the color.
+ if (mode != MODE_TEXT_CURSOR && is_cursor_pos) {
+ out_data.color = load_color(unpack4u8(cursor_color_packed_4u8), use_linear_blending);
+ }
+}
diff --git a/src/renderer/shaders/glsl/common.glsl b/src/renderer/shaders/glsl/common.glsl
new file mode 100644
index 000000000..a0ed9f7b4
--- /dev/null
+++ b/src/renderer/shaders/glsl/common.glsl
@@ -0,0 +1,156 @@
+#version 430 core
+
+// These are common definitions to be shared across shaders, the first
+// line of any shader that needs these should be `#include "common.glsl"`.
+//
+// Included in this file are:
+// - The interface block for the global uniforms.
+// - Functions for unpacking values.
+// - Functions for working with colors.
+
+//----------------------------------------------------------------------------//
+// Global Uniforms
+//----------------------------------------------------------------------------//
+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;
+ uniform uint padding_extend;
+ uniform float min_contrast;
+ uniform uint cursor_pos_packed_2u16;
+ uniform uint cursor_color_packed_4u8;
+ uniform uint bg_color_packed_4u8;
+ uniform uint bools;
+};
+
+// Bools
+const uint CURSOR_WIDE = 1u;
+const uint USE_DISPLAY_P3 = 2u;
+const uint USE_LINEAR_BLENDING = 4u;
+const uint USE_LINEAR_CORRECTION = 8u;
+
+// Padding extend enum
+const uint EXTEND_LEFT = 1u;
+const uint EXTEND_RIGHT = 2u;
+const uint EXTEND_UP = 4u;
+const uint EXTEND_DOWN = 8u;
+
+//----------------------------------------------------------------------------//
+// Functions for Unpacking Values
+//----------------------------------------------------------------------------//
+// NOTE: These unpack functions assume little-endian.
+// If this ever becomes a problem... oh dear!
+
+uvec4 unpack4u8(uint packed_value) {
+ return uvec4(
+ uint(packed_value >> 0) & uint(0xFF),
+ uint(packed_value >> 8) & uint(0xFF),
+ uint(packed_value >> 16) & uint(0xFF),
+ uint(packed_value >> 24) & uint(0xFF)
+ );
+}
+
+uvec2 unpack2u16(uint packed_value) {
+ return uvec2(
+ uint(packed_value >> 0) & uint(0xFFFF),
+ uint(packed_value >> 16) & uint(0xFFFF)
+ );
+}
+
+ivec2 unpack2i16(int packed_value) {
+ return ivec2(
+ (packed_value << 16) >> 16,
+ (packed_value << 0) >> 16
+ );
+}
+
+//----------------------------------------------------------------------------//
+// Color Functions
+//----------------------------------------------------------------------------//
+
+// Compute the luminance of the provided color.
+//
+// Takes colors in linear RGB space. If your colors are gamma
+// encoded, linearize them before using them with this function.
+float luminance(vec3 color) {
+ return dot(color, vec3(0.2126f, 0.7152f, 0.0722f));
+}
+
+// https://www.w3.org/TR/2008/REC-WCAG20-20081211/#contrast-ratiodef
+//
+// Takes colors in linear RGB space. If your colors are gamma
+// encoded, linearize them before using them with this function.
+float contrast_ratio(vec3 color1, vec3 color2) {
+ float luminance1 = luminance(color1) + 0.05;
+ float luminance2 = luminance(color2) + 0.05;
+ return max(luminance1, luminance2) / min(luminance1, luminance2);
+}
+
+// Return the fg if the contrast ratio is greater than min, otherwise
+// return a color that satisfies the contrast ratio. Currently, the color
+// is always white or black, whichever has the highest contrast ratio.
+//
+// Takes colors in linear RGB space. If your colors are gamma
+// encoded, linearize them before using them with this function.
+vec4 contrasted_color(float min_ratio, vec4 fg, vec4 bg) {
+ float ratio = contrast_ratio(fg.rgb, bg.rgb);
+ if (ratio < min_ratio) {
+ float white_ratio = contrast_ratio(vec3(1.0, 1.0, 1.0), bg.rgb);
+ float black_ratio = contrast_ratio(vec3(0.0, 0.0, 0.0), bg.rgb);
+ if (white_ratio > black_ratio) {
+ return vec4(1.0);
+ } else {
+ return vec4(0.0);
+ }
+ }
+
+ return fg;
+}
+
+// Converts a color from sRGB gamma encoding to linear.
+vec4 linearize(vec4 srgb) {
+ bvec3 cutoff = lessThanEqual(srgb.rgb, vec3(0.04045));
+ vec3 higher = pow((srgb.rgb + vec3(0.055)) / vec3(1.055), vec3(2.4));
+ vec3 lower = srgb.rgb / vec3(12.92);
+
+ return vec4(mix(higher, lower, cutoff), srgb.a);
+}
+float linearize(float v) {
+ return v <= 0.04045 ? v / 12.92 : pow((v + 0.055) / 1.055, 2.4);
+}
+
+// Converts a color from linear to sRGB gamma encoding.
+vec4 unlinearize(vec4 linear) {
+ bvec3 cutoff = lessThanEqual(linear.rgb, vec3(0.0031308));
+ vec3 higher = pow(linear.rgb, vec3(1.0 / 2.4)) * vec3(1.055) - vec3(0.055);
+ vec3 lower = linear.rgb * vec3(12.92);
+
+ return vec4(mix(higher, lower, cutoff), linear.a);
+}
+float unlinearize(float v) {
+ return v <= 0.0031308 ? v * 12.92 : pow(v, 1.0 / 2.4) * 1.055 - 0.055;
+}
+
+// Load a 4 byte RGBA non-premultiplied color and linearize
+// and convert it as necessary depending on the provided info.
+//
+// `linear` controls whether the returned color is linear or gamma encoded.
+vec4 load_color(
+ uvec4 in_color,
+ bool linear
+) {
+ // 0 .. 255 -> 0.0 .. 1.0
+ vec4 color = vec4(in_color) / vec4(255.0f);
+
+ // Linearize if necessary.
+ if (linear) color = linearize(color);
+
+ // Premultiply our color by its alpha.
+ color.rgb *= color.a;
+
+ return color;
+}
+
+//----------------------------------------------------------------------------//
diff --git a/src/renderer/shaders/glsl/full_screen.v.glsl b/src/renderer/shaders/glsl/full_screen.v.glsl
new file mode 100644
index 000000000..b89cedfa5
--- /dev/null
+++ b/src/renderer/shaders/glsl/full_screen.v.glsl
@@ -0,0 +1,24 @@
+#version 330 core
+
+void main() {
+ 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;
+}
diff --git a/src/renderer/shaders/glsl/image.f.glsl b/src/renderer/shaders/glsl/image.f.glsl
new file mode 100644
index 000000000..4f89d7a78
--- /dev/null
+++ b/src/renderer/shaders/glsl/image.f.glsl
@@ -0,0 +1,21 @@
+#include "common.glsl"
+
+layout(binding = 0) uniform sampler2D image;
+
+in vec2 tex_coord;
+
+layout(location = 0) out vec4 out_FragColor;
+
+void main() {
+ bool use_linear_blending = (bools & USE_LINEAR_BLENDING) != 0;
+
+ vec4 rgba = texture(image, tex_coord);
+
+ if (!use_linear_blending) {
+ rgba = unlinearize(rgba);
+ }
+
+ rgba.rgb *= vec3(rgba.a);
+
+ out_FragColor = rgba;
+}
diff --git a/src/renderer/shaders/glsl/image.v.glsl b/src/renderer/shaders/glsl/image.v.glsl
new file mode 100644
index 000000000..779fae32f
--- /dev/null
+++ b/src/renderer/shaders/glsl/image.v.glsl
@@ -0,0 +1,47 @@
+#include "common.glsl"
+
+layout(binding = 0) uniform sampler2D image;
+
+layout(location = 0) in vec2 grid_pos;
+layout(location = 1) in vec2 cell_offset;
+layout(location = 2) in vec4 source_rect;
+layout(location = 3) in vec2 dest_size;
+
+out vec2 tex_coord;
+
+void main() {
+ int vid = gl_VertexID;
+
+ // We use a triangle strip with 4 vertices to render quads,
+ // so we determine which corner of the cell this vertex is in
+ // based on the vertex ID.
+ //
+ // 0 --> 1
+ // | .'|
+ // | / |
+ // | L |
+ // 2 --> 3
+ //
+ // 0 = top-left (0, 0)
+ // 1 = top-right (1, 0)
+ // 2 = bot-left (0, 1)
+ // 3 = bot-right (1, 1)
+ vec2 corner;
+ corner.x = float(vid == 1 || vid == 3);
+ corner.y = float(vid == 2 || vid == 3);
+
+ // The texture coordinates start at our source x/y
+ // and add the width/height depending on the corner.
+ 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;
+ image_pos += dest_size * corner;
+
+ gl_Position = projection_matrix * vec4(image_pos.xy, 1.0, 1.0);
+}
diff --git a/src/renderer/shaders/image.f.glsl b/src/renderer/shaders/image.f.glsl
deleted file mode 100644
index e4aa9ef8e..000000000
--- a/src/renderer/shaders/image.f.glsl
+++ /dev/null
@@ -1,29 +0,0 @@
-#version 330 core
-
-in vec2 tex_coord;
-
-layout(location = 0) out vec4 out_FragColor;
-
-uniform sampler2D image;
-
-// Converts a color from linear to sRGB gamma encoding.
-vec4 unlinearize(vec4 linear) {
- bvec3 cutoff = lessThan(linear.rgb, vec3(0.0031308));
- vec3 higher = pow(linear.rgb, vec3(1.0/2.4)) * vec3(1.055) - vec3(0.055);
- vec3 lower = linear.rgb * vec3(12.92);
-
- return vec4(mix(higher, lower, cutoff), linear.a);
-}
-
-void main() {
- vec4 color = texture(image, tex_coord);
-
- // Our texture is stored with an sRGB internal format,
- // which means that the values are linearized when we
- // sample the texture, but for now we actually want to
- // output the color with gamma compression, so we do
- // that.
- color = unlinearize(color);
-
- out_FragColor = vec4(color.rgb * color.a, color.a);
-}
diff --git a/src/renderer/shaders/image.v.glsl b/src/renderer/shaders/image.v.glsl
deleted file mode 100644
index e3d07ca9e..000000000
--- a/src/renderer/shaders/image.v.glsl
+++ /dev/null
@@ -1,44 +0,0 @@
-#version 330 core
-
-layout (location = 0) in vec2 grid_pos;
-layout (location = 1) in vec2 cell_offset;
-layout (location = 2) in vec4 source_rect;
-layout (location = 3) in vec2 dest_size;
-
-out vec2 tex_coord;
-
-uniform sampler2D image;
-uniform vec2 cell_size;
-uniform mat4 projection;
-
-void main() {
- // The size of the image in pixels
- vec2 image_size = textureSize(image, 0);
-
- // Turn the cell position into a vertex point depending on the
- // gl_VertexID. Since we use instanced drawing, we have 4 vertices
- // for each corner of the cell. We can use gl_VertexID to determine
- // which one we're looking at. Using this, we can use 1 or 0 to keep
- // or discard the value for the vertex.
- //
- // 0 = top-right
- // 1 = bot-right
- // 2 = bot-left
- // 3 = top-left
- vec2 position;
- position.x = (gl_VertexID == 0 || gl_VertexID == 1) ? 1. : 0.;
- position.y = (gl_VertexID == 0 || gl_VertexID == 3) ? 0. : 1.;
-
- // The texture coordinates start at our source x/y, then add the width/height
- // as enabled by our instance id, then normalize to [0, 1]
- tex_coord = source_rect.xy;
- tex_coord += source_rect.zw * position;
- tex_coord /= image_size;
-
- // 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;
- image_pos += dest_size * position;
-
- gl_Position = projection * vec4(image_pos.xy, 0, 1.0);
-}
diff --git a/src/renderer/shaders/cell.metal b/src/renderer/shaders/shaders.metal
index 5b3875221..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,53 +217,245 @@ vertex FullScreenVertexOut full_screen_vertex(
}
//-------------------------------------------------------------------
-// Cell Background Shader
+// Background Color Shader
//-------------------------------------------------------------------
-#pragma mark - Cell BG Shader
+#pragma mark - BG Color Shader
+
+fragment float4 bg_color_fragment(
+ FullScreenVertexOut in [[stage_in]],
+ constant Uniforms& uniforms [[buffer(1)]]
+) {
+ 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 CellBgVertexOut {
+struct BgImageVertexOut {
float4 position [[position]];
- float4 bg_color;
+ float4 bg_color [[flat]];
+ float2 offset [[flat]];
+ float2 scale [[flat]];
+ float opacity [[flat]];
+ bool repeat [[flat]];
};
-vertex CellBgVertexOut cell_bg_vertex(
+vertex BgImageVertexOut bg_image_vertex(
uint vid [[vertex_id]],
+ BgImageVertexIn in [[stage_in]],
+ texture2d<float> image [[texture(0)]],
constant Uniforms& uniforms [[buffer(1)]]
) {
- CellBgVertexOut out;
+ 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 cell_bg_fragment(
- CellBgVertexOut in [[stage_in]],
- constant uchar4 *cells [[buffer(0)]],
+fragment float4 bg_image_fragment(
+ BgImageVertexOut in [[stage_in]],
+ texture2d<float> 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(
+ 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 = float4(0.0);
- // If we have any background transparency then we render bg-colored cells as
- // fully transparent, since the background is handled by the layer bg color
- // and we don't want to double up our bg color, but if our bg color is fully
- // opaque then our layer is opaque and can't handle transparency, so we need
- // to return the bg color directly instead.
- if (uniforms.bg_color.a == 255) {
- bg = in.bg_color;
- }
// Clamp x position, extends edge bg colors in to padding on sides.
if (grid_pos.x < 0) {
@@ -297,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
@@ -374,19 +558,23 @@ vertex CellTextVertexOut cell_text_vertex(
// Convert the grid x, y into world space x, y by accounting for cell size
float2 cell_pos = uniforms.cell_size * float2(in.grid_pos);
- // Turn the cell position into a vertex point depending on the
- // vertex ID. Since we use instanced drawing, we have 4 vertices
- // for each corner of the cell. We can use vertex ID to determine
- // which one we're looking at. Using this, we can use 1 or 0 to keep
- // or discard the value for the vertex.
+ // We use a triangle strip with 4 vertices to render quads,
+ // so we determine which corner of the cell this vertex is in
+ // based on the vertex ID.
//
- // 0 = top-right
- // 1 = bot-right
- // 2 = bot-left
- // 3 = top-left
+ // 0 --> 1
+ // | .'|
+ // | / |
+ // | L |
+ // 2 --> 3
+ //
+ // 0 = top-left (0, 0)
+ // 1 = top-right (1, 0)
+ // 2 = bot-left (0, 1)
+ // 3 = bot-right (1, 1)
float2 corner;
- corner.x = (vid == 0 || vid == 1) ? 1.0f : 0.0f;
- corner.y = (vid == 0 || vid == 3) ? 0.0f : 1.0f;
+ corner.x = float(vid == 1 || vid == 3);
+ corner.y = float(vid == 2 || vid == 3);
CellTextVertexOut out;
out.mode = in.mode;
@@ -466,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
@@ -502,7 +697,7 @@ fragment float4 cell_text_fragment(
CellTextVertexOut in [[stage_in]],
texture2d<float> textureGrayscale [[texture(0)]],
texture2d<float> textureColor [[texture(1)]],
- constant Uniforms& uniforms [[buffer(2)]]
+ constant Uniforms& uniforms [[buffer(1)]]
) {
constexpr sampler textureSampler(
coord::pixel,
@@ -570,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;
@@ -621,19 +816,23 @@ vertex ImageVertexOut image_vertex(
texture2d<uint> image [[texture(0)]],
constant Uniforms& uniforms [[buffer(1)]]
) {
- // Turn the image position into a vertex point depending on the
- // vertex ID. Since we use instanced drawing, we have 4 vertices
- // for each corner of the cell. We can use vertex ID to determine
- // which one we're looking at. Using this, we can use 1 or 0 to keep
- // or discard the value for the vertex.
+ // We use a triangle strip with 4 vertices to render quads,
+ // so we determine which corner of the cell this vertex is in
+ // based on the vertex ID.
+ //
+ // 0 --> 1
+ // | .'|
+ // | / |
+ // | L |
+ // 2 --> 3
//
- // 0 = top-right
- // 1 = bot-right
- // 2 = bot-left
- // 3 = top-left
+ // 0 = top-left (0, 0)
+ // 1 = top-right (1, 0)
+ // 2 = bot-left (0, 1)
+ // 3 = bot-right (1, 1)
float2 corner;
- corner.x = (vid == 0 || vid == 1) ? 1.0f : 0.0f;
- corner.y = (vid == 0 || vid == 3) ? 0.0f : 1.0f;
+ corner.x = float(vid == 1 || vid == 3);
+ corner.y = float(vid == 2 || vid == 3);
// The texture coordinates start at our source x/y
// and add the width/height depending on the corner.
diff --git a/src/renderer/shaders/shadertoy_prefix.glsl b/src/renderer/shaders/shadertoy_prefix.glsl
index a1a220bd4..6d9cf0f68 100644
--- a/src/renderer/shaders/shadertoy_prefix.glsl
+++ b/src/renderer/shaders/shadertoy_prefix.glsl
@@ -1,24 +1,29 @@
#version 430 core
-layout(binding = 0) uniform Globals {
- uniform vec3 iResolution;
- uniform float iTime;
- uniform float iTimeDelta;
- uniform float iFrameRate;
- uniform int iFrame;
- uniform float iChannelTime[4];
- uniform vec3 iChannelResolution[4];
- uniform vec4 iMouse;
- uniform vec4 iDate;
- uniform float iSampleRate;
+layout(binding = 1, std140) uniform Globals {
+ uniform vec3 iResolution;
+ uniform float iTime;
+ uniform float iTimeDelta;
+ uniform float iFrameRate;
+ uniform int iFrame;
+ uniform float iChannelTime[4];
+ uniform vec3 iChannelResolution[4];
+ uniform vec4 iMouse;
+ uniform vec4 iDate;
+ uniform float iSampleRate;
+ uniform vec4 iCurrentCursor;
+ uniform vec4 iPreviousCursor;
+ uniform vec4 iCurrentCursorColor;
+ uniform vec4 iPreviousCursorColor;
+ uniform float iTimeCursorChange;
};
-layout(binding = 0) uniform sampler2D iChannel0;
+layout(binding = 0) uniform sampler2D iChannel0;
// These are unused currently by Ghostty:
-// layout(binding = 1) uniform sampler2D iChannel1;
-// layout(binding = 2) uniform sampler2D iChannel2;
-// layout(binding = 3) uniform sampler2D iChannel3;
+// layout(binding = 1) uniform sampler2D iChannel1;
+// layout(binding = 2) uniform sampler2D iChannel2;
+// layout(binding = 3) uniform sampler2D iChannel3;
layout(location = 0) in vec4 gl_FragCoord;
layout(location = 0) out vec4 _fragColor;
diff --git a/src/renderer/shadertoy.zig b/src/renderer/shadertoy.zig
index 45d86cbfe..576237587 100644
--- a/src/renderer/shadertoy.zig
+++ b/src/renderer/shadertoy.zig
@@ -9,6 +9,25 @@ const configpkg = @import("../config.zig");
const log = std.log.scoped(.shadertoy);
+/// The uniform struct used for shadertoy shaders.
+pub const Uniforms = extern struct {
+ resolution: [3]f32 align(16),
+ time: f32 align(4),
+ time_delta: f32 align(4),
+ frame_rate: f32 align(4),
+ frame: i32 align(4),
+ channel_time: [4][4]f32 align(16),
+ channel_resolution: [4][4]f32 align(16),
+ mouse: [4]f32 align(16),
+ date: [4]f32 align(16),
+ sample_rate: f32 align(4),
+ current_cursor: [4]f32 align(16),
+ previous_cursor: [4]f32 align(16),
+ current_cursor_color: [4]f32 align(16),
+ previous_cursor_color: [4]f32 align(16),
+ cursor_change_time: f32 align(4),
+};
+
/// The target to load shaders for.
pub const Target = enum { glsl, msl };
@@ -205,18 +224,25 @@ pub const SpirvLog = struct {
/// Convert SPIR-V binary to MSL.
pub fn mslFromSpv(alloc: Allocator, spv: []const u8) ![:0]const u8 {
- return try spvCross(alloc, spvcross.c.SPVC_BACKEND_MSL, spv, null);
+ const c = spvcross.c;
+ return try spvCross(alloc, spvcross.c.SPVC_BACKEND_MSL, spv, (struct {
+ fn setOptions(options: c.spvc_compiler_options) error{SpvcFailed}!void {
+ // We enable decoration binding, because we need this
+ // to properly locate the uniform block to index 1.
+ if (c.spvc_compiler_options_set_bool(
+ options,
+ c.SPVC_COMPILER_OPTION_MSL_ENABLE_DECORATION_BINDING,
+ c.SPVC_TRUE,
+ ) != c.SPVC_SUCCESS) {
+ return error.SpvcFailed;
+ }
+ }
+ }).setOptions);
}
-/// Convert SPIR-V binary to GLSL..
+/// Convert SPIR-V binary to GLSL.
pub fn glslFromSpv(alloc: Allocator, spv: []const u8) ![:0]const u8 {
- // Our minimum version for shadertoy shaders is OpenGL 4.2 because
- // Spirv-Cross generates binding locations for uniforms which is
- // only supported in OpenGL 4.2 and above.
- //
- // If we can figure out a way to NOT do this then we can lower this
- // version.
- const GLSL_VERSION = 420;
+ const GLSL_VERSION = 430;
const c = spvcross.c;
return try spvCross(alloc, c.SPVC_BACKEND_GLSL, spv, (struct {
diff --git a/src/renderer/size.zig b/src/renderer/size.zig
index 83e921a26..b26c1581e 100644
--- a/src/renderer/size.zig
+++ b/src/renderer/size.zig
@@ -22,7 +22,7 @@ pub const Size = struct {
/// taking the screen size, removing padding, and dividing by the cell
/// dimensions.
pub fn grid(self: Size) GridSize {
- return GridSize.init(self.screen.subPadding(self.padding), self.cell);
+ return .init(self.screen.subPadding(self.padding), self.cell);
}
/// The size of the terminal. This is the same as the screen without
@@ -39,7 +39,7 @@ pub const Size = struct {
self.padding = explicit;
// Now we can calculate the balanced padding
- self.padding = Padding.balanced(
+ self.padding = .balanced(
self.screen,
self.grid(),
self.cell,
diff --git a/src/shell-integration/bash/ghostty.bash b/src/shell-integration/bash/ghostty.bash
index 0cfd41663..0766198f9 100644
--- a/src/shell-integration/bash/ghostty.bash
+++ b/src/shell-integration/bash/ghostty.bash
@@ -15,10 +15,8 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
-# We need to be in interactive mode and we need to have the Ghostty
-# resources dir set which also tells us we're running in Ghostty.
+# We need to be in interactive mode to proceed.
if [[ "$-" != *i* ]] ; then builtin return; fi
-if [ -z "$GHOSTTY_RESOURCES_DIR" ]; then builtin return; fi
# When automatic shell integration is active, we were started in POSIX
# mode and need to manually recreate the bash startup sequence.
@@ -98,7 +96,7 @@ if [[ "$GHOSTTY_SHELL_FEATURES" == *"sudo"* && -n "$TERMINFO" ]]; then
fi
# Import bash-preexec, safe to do multiple times
-builtin source "$GHOSTTY_RESOURCES_DIR/shell-integration/bash/bash-preexec.sh"
+builtin source "$(dirname -- "${BASH_SOURCE[0]}")/bash-preexec.sh"
# This is set to 1 when we're executing a command so that we don't
# send prompt marks multiple times.
diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig
index 95519fe99..9838bfb53 100644
--- a/src/terminal/PageList.zig
+++ b/src/terminal/PageList.zig
@@ -287,8 +287,8 @@ fn initPages(
// Initialize the first set of pages to contain our viewport so that
// the top of the first page is always the active area.
node.* = .{
- .data = Page.initBuf(
- OffsetBuf.init(page_buf),
+ .data = .initBuf(
+ .init(page_buf),
Page.layout(cap),
),
};
@@ -472,7 +472,7 @@ pub fn clone(
};
// Setup our pools
- break :alloc try MemoryPool.init(
+ break :alloc try .init(
alloc,
std.heap.page_allocator,
page_count,
@@ -908,16 +908,6 @@ const ReflowCursor = struct {
const cell = &cells[x];
x += 1;
- // std.log.warn("\nsrc_y={} src_x={} dst_y={} dst_x={} dst_cols={} cp={} wide={}", .{
- // src_y,
- // x,
- // self.y,
- // self.x,
- // self.page.size.cols,
- // cell.content.codepoint,
- // cell.wide,
- // });
-
// Copy cell contents.
switch (cell.content_tag) {
.codepoint,
@@ -937,8 +927,15 @@ const ReflowCursor = struct {
};
// Decrement the source position so that when we
- // loop we'll process this source cell again.
+ // loop we'll process this source cell again,
+ // since we can't copy it into a spacer head.
x -= 1;
+
+ // Move to the next row (this sets pending wrap
+ // which will cause us to wrap on the next
+ // iteration).
+ self.cursorForward();
+ continue;
} else {
self.page_cell.* = cell.*;
}
@@ -990,6 +987,17 @@ const ReflowCursor = struct {
self.page_cell.hyperlink = false;
self.page_cell.style_id = stylepkg.default_id;
+ // std.log.warn("\nsrc_y={} src_x={} dst_y={} dst_x={} dst_cols={} cp={X} wide={} page_cell_wide={}", .{
+ // src_y,
+ // x,
+ // self.y,
+ // self.x,
+ // self.page.size.cols,
+ // cell.content.codepoint,
+ // cell.wide,
+ // self.page_cell.wide,
+ // });
+
// Copy grapheme data.
if (cell.content_tag == .codepoint_grapheme) {
// Copy the graphemes
@@ -1201,7 +1209,7 @@ const ReflowCursor = struct {
node.data.size.rows = 1;
list.pages.insertAfter(self.node, node);
- self.* = ReflowCursor.init(node);
+ self.* = .init(node);
self.new_rows = new_rows;
}
@@ -1817,7 +1825,7 @@ pub fn grow(self: *PageList) !?*List.Node {
@memset(buf, 0);
// Initialize our new page and reinsert it as the last
- first.data = Page.initBuf(OffsetBuf.init(buf), layout);
+ first.data = .initBuf(.init(buf), layout);
first.data.size.rows = 1;
self.pages.insertAfter(last, first);
@@ -1989,7 +1997,7 @@ fn createPageExt(
// to undefined, 0xAA.
if (comptime std.debug.runtime_safety) @memset(page_buf, 0);
- page.* = .{ .data = Page.initBuf(OffsetBuf.init(page_buf), layout) };
+ page.* = .{ .data = .initBuf(.init(page_buf), layout) };
page.data.size.rows = 0;
if (total_size) |v| {
@@ -3572,6 +3580,74 @@ pub const Pin = struct {
return result;
}
+ /// Move the pin left n columns, stopping at the start of the row.
+ pub fn leftClamp(self: Pin, n: size.CellCountInt) Pin {
+ var result = self;
+ result.x -|= n;
+ return result;
+ }
+
+ /// Move the pin right n columns, stopping at the end of the row.
+ pub fn rightClamp(self: Pin, n: size.CellCountInt) Pin {
+ var result = self;
+ result.x = @min(self.x +| n, self.node.data.size.cols - 1);
+ return result;
+ }
+
+ /// Move the pin left n cells, wrapping to the previous row as needed.
+ ///
+ /// If the offset goes beyond the top of the screen, returns null.
+ ///
+ /// TODO: Unit tests.
+ pub fn leftWrap(self: Pin, n: usize) ?Pin {
+ // NOTE: This assumes that all pages have the same width, which may
+ // be violated under certain circumstances by incomplete reflow.
+ const cols = self.node.data.size.cols;
+ const remaining_in_row = self.x;
+
+ if (n <= remaining_in_row) return self.left(n);
+
+ const extra_after_remaining = n - remaining_in_row;
+
+ const rows_off = 1 + extra_after_remaining / cols;
+
+ switch (self.upOverflow(rows_off)) {
+ .offset => |v| {
+ var result = v;
+ result.x = @intCast(cols - extra_after_remaining % cols);
+ return result;
+ },
+ .overflow => return null,
+ }
+ }
+
+ /// Move the pin right n cells, wrapping to the next row as needed.
+ ///
+ /// If the offset goes beyond the bottom of the screen, returns null.
+ ///
+ /// TODO: Unit tests.
+ pub fn rightWrap(self: Pin, n: usize) ?Pin {
+ // NOTE: This assumes that all pages have the same width, which may
+ // be violated under certain circumstances by incomplete reflow.
+ const cols = self.node.data.size.cols;
+ const remaining_in_row = cols - self.x - 1;
+
+ if (n <= remaining_in_row) return self.right(n);
+
+ const extra_after_remaining = n - remaining_in_row;
+
+ const rows_off = 1 + extra_after_remaining / cols;
+
+ switch (self.downOverflow(rows_off)) {
+ .offset => |v| {
+ var result = v;
+ result.x = @intCast(extra_after_remaining % cols - 1);
+ return result;
+ },
+ .overflow => return null,
+ }
+ }
+
/// Move the pin down a certain number of rows, or return null if
/// the pin goes beyond the end of the screen.
pub fn down(self: Pin, n: usize) ?Pin {
@@ -8307,6 +8383,125 @@ test "PageList resize reflow less cols to wrap a wide char" {
}
}
+test "PageList resize reflow less cols to wrap a multi-codepoint grapheme with a spacer head" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 4, 2, 0);
+ defer s.deinit();
+ {
+ try testing.expect(s.pages.first == s.pages.last);
+ const page = &s.pages.first.?.data;
+
+ // We want to make the screen look like this:
+ //
+ // 👨‍👨‍👦‍👦👨‍👨‍👦‍👦
+
+ // First family emoji at (0, 0)
+ {
+ const rac = page.getRowAndCell(0, 0);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 0x1F468 }, // First codepoint of the grapheme
+ .wide = .wide,
+ };
+ try page.setGraphemes(rac.row, rac.cell, &.{
+ 0x200D, 0x1F468,
+ 0x200D, 0x1F466,
+ 0x200D, 0x1F466,
+ });
+ }
+ {
+ const rac = page.getRowAndCell(1, 0);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 0 },
+ .wide = .spacer_tail,
+ };
+ }
+ // Second family emoji at (2, 0)
+ {
+ const rac = page.getRowAndCell(2, 0);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 0x1F468 }, // First codepoint of the grapheme
+ .wide = .wide,
+ };
+ try page.setGraphemes(rac.row, rac.cell, &.{
+ 0x200D, 0x1F468,
+ 0x200D, 0x1F466,
+ 0x200D, 0x1F466,
+ });
+ }
+ {
+ const rac = page.getRowAndCell(3, 0);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 0 },
+ .wide = .spacer_tail,
+ };
+ }
+ }
+
+ // Resize
+ try s.resize(.{ .cols = 3, .reflow = true });
+ try testing.expectEqual(@as(usize, 3), s.cols);
+ try testing.expectEqual(@as(usize, 2), s.totalRows());
+
+ {
+ try testing.expect(s.pages.first == s.pages.last);
+ const page = &s.pages.first.?.data;
+
+ {
+ const rac = page.getRowAndCell(0, 0);
+ try testing.expectEqual(@as(u21, 0x1F468), rac.cell.content.codepoint);
+ try testing.expectEqual(pagepkg.Cell.Wide.wide, rac.cell.wide);
+
+ const cps = page.lookupGrapheme(rac.cell).?;
+ try testing.expectEqual(@as(usize, 6), cps.len);
+ try testing.expectEqual(@as(u21, 0x200D), cps[0]);
+ try testing.expectEqual(@as(u21, 0x1F468), cps[1]);
+ try testing.expectEqual(@as(u21, 0x200D), cps[2]);
+ try testing.expectEqual(@as(u21, 0x1F466), cps[3]);
+ try testing.expectEqual(@as(u21, 0x200D), cps[4]);
+ try testing.expectEqual(@as(u21, 0x1F466), cps[5]);
+
+ // Row should be wrapped
+ try testing.expect(rac.row.wrap);
+ }
+ {
+ const rac = page.getRowAndCell(1, 0);
+ try testing.expectEqual(@as(u21, 0), rac.cell.content.codepoint);
+ try testing.expectEqual(pagepkg.Cell.Wide.spacer_tail, rac.cell.wide);
+ }
+ {
+ const rac = page.getRowAndCell(2, 0);
+ try testing.expectEqual(@as(u21, 0), rac.cell.content.codepoint);
+ try testing.expectEqual(pagepkg.Cell.Wide.spacer_head, rac.cell.wide);
+ }
+
+ {
+ const rac = page.getRowAndCell(0, 0);
+ try testing.expectEqual(@as(u21, 0x1F468), rac.cell.content.codepoint);
+ try testing.expectEqual(pagepkg.Cell.Wide.wide, rac.cell.wide);
+
+ const cps = page.lookupGrapheme(rac.cell).?;
+ try testing.expectEqual(@as(usize, 6), cps.len);
+ try testing.expectEqual(@as(u21, 0x200D), cps[0]);
+ try testing.expectEqual(@as(u21, 0x1F468), cps[1]);
+ try testing.expectEqual(@as(u21, 0x200D), cps[2]);
+ try testing.expectEqual(@as(u21, 0x1F466), cps[3]);
+ try testing.expectEqual(@as(u21, 0x200D), cps[4]);
+ try testing.expectEqual(@as(u21, 0x1F466), cps[5]);
+ }
+ {
+ const rac = page.getRowAndCell(1, 1);
+ try testing.expectEqual(@as(u21, 0), rac.cell.content.codepoint);
+ try testing.expectEqual(pagepkg.Cell.Wide.spacer_tail, rac.cell.wide);
+ }
+ }
+}
+
test "PageList resize reflow less cols copy kitty placeholder" {
const testing = std.testing;
const alloc = testing.allocator;
diff --git a/src/terminal/Parser.zig b/src/terminal/Parser.zig
index 4e74f04ba..ec3f322f6 100644
--- a/src/terminal/Parser.zig
+++ b/src/terminal/Parser.zig
@@ -217,7 +217,7 @@ intermediates_idx: u8 = 0,
/// Param tracking, building
params: [MAX_PARAMS]u16 = undefined,
-params_sep: Action.CSI.SepList = Action.CSI.SepList.initEmpty(),
+params_sep: Action.CSI.SepList = .initEmpty(),
params_idx: u8 = 0,
param_acc: u16 = 0,
param_acc_idx: u8 = 0,
@@ -395,7 +395,7 @@ fn doAction(self: *Parser, action: TransitionAction, c: u8) ?Action {
pub fn clear(self: *Parser) void {
self.intermediates_idx = 0;
self.params_idx = 0;
- self.params_sep = Action.CSI.SepList.initEmpty();
+ self.params_sep = .initEmpty();
self.param_acc = 0;
self.param_acc_idx = 0;
}
@@ -877,7 +877,10 @@ test "osc: change window title (end in esc)" {
// https://github.com/darrenstarr/VtNetCore/pull/14
// Saw this on HN, decided to add a test case because why not.
test "osc: 112 incomplete sequence" {
- var p = init();
+ var p: Parser = init();
+ defer p.deinit();
+ p.osc_parser.alloc = std.testing.allocator;
+
_ = p.next(0x1B);
_ = p.next(']');
_ = p.next('1');
@@ -892,8 +895,20 @@ test "osc: 112 incomplete sequence" {
try testing.expect(a[2] == null);
const cmd = a[0].?.osc_dispatch;
- try testing.expect(cmd == .reset_color);
- try testing.expectEqual(cmd.reset_color.kind, .cursor);
+ try testing.expect(cmd == .color_operation);
+ try testing.expectEqual(cmd.color_operation.terminator, .bel);
+ try testing.expect(cmd.color_operation.source == .reset_cursor);
+ try testing.expect(cmd.color_operation.operations.count() == 1);
+ var it = cmd.color_operation.operations.constIterator(0);
+ {
+ const op = it.next().?;
+ try testing.expect(op.* == .reset);
+ try testing.expectEqual(
+ osc.Command.ColorOperation.Kind.cursor,
+ op.reset,
+ );
+ }
+ try std.testing.expect(it.next() == null);
}
}
diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig
index 9ab4b23e2..5b772ab84 100644
--- a/src/terminal/Screen.zig
+++ b/src/terminal/Screen.zig
@@ -171,7 +171,7 @@ pub const SavedCursor = struct {
/// State required for all charset operations.
pub const CharsetState = struct {
/// The list of graphical charsets by slot
- charsets: CharsetArray = CharsetArray.initFill(charsets.Charset.utf8),
+ charsets: CharsetArray = .initFill(charsets.Charset.utf8),
/// GL is the slot to use when using a 7-bit printable char (up to 127)
/// GR used for 8-bit printable chars.
@@ -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 = .{
@@ -2433,7 +2448,7 @@ pub fn selectLine(self: *const Screen, opts: SelectLine) ?Selection {
return null;
};
- return Selection.init(start, end, false);
+ return .init(start, end, false);
}
/// Return the selection for all contents on the screen. Surrounding
@@ -2489,7 +2504,7 @@ pub fn selectAll(self: *Screen) ?Selection {
return null;
};
- return Selection.init(start, end, false);
+ return .init(start, end, false);
}
/// Select the nearest word to start point that is between start_pt and
@@ -2624,7 +2639,7 @@ pub fn selectWord(self: *Screen, pin: Pin) ?Selection {
break :start prev;
};
- return Selection.init(start, end, false);
+ return .init(start, end, false);
}
/// Select the command output under the given point. The limits of the output
@@ -2724,7 +2739,7 @@ pub fn selectOutput(self: *Screen, pin: Pin) ?Selection {
break :boundary it_prev;
};
- return Selection.init(start, end, false);
+ return .init(start, end, false);
}
/// Returns the selection bounds for the prompt at the given point. If the
@@ -2805,7 +2820,7 @@ pub fn selectPrompt(self: *Screen, pin: Pin) ?Selection {
break :end it_prev;
};
- return Selection.init(start, end, false);
+ return .init(start, end, false);
}
pub const LineIterator = struct {
@@ -5287,6 +5302,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;
@@ -7857,7 +7911,7 @@ test "Screen: selectOutput" {
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("$ input3\n"); // 8
try s.testWriteString("output3\n"); // 9
try s.testWriteString("output3\n"); // 10
try s.testWriteString("output3"); // 11
@@ -7945,14 +7999,14 @@ 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("$ input1\n");
try s.testWriteString("output1\n");
try s.testWriteString("prompt2\n");
{
@@ -7988,7 +8042,7 @@ test "Screen: selectPrompt basics" {
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("$ input3\n"); // 6
try s.testWriteString("output3\n"); // 7
try s.testWriteString("output3\n"); // 8
try s.testWriteString("output3"); // 9
@@ -8203,7 +8257,7 @@ test "Screen: promptPath" {
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("$ input3\n"); // 6
try s.testWriteString("output3\n"); // 7
try s.testWriteString("output3\n"); // 8
try s.testWriteString("output3"); // 9
diff --git a/src/terminal/Selection.zig b/src/terminal/Selection.zig
index a90595d20..267f223d5 100644
--- a/src/terminal/Selection.zig
+++ b/src/terminal/Selection.zig
@@ -228,7 +228,7 @@ pub fn order(self: Selection, s: *const Screen) Order {
/// Note that only forward and reverse are useful desired orders for this
/// function. All other orders act as if forward order was desired.
pub fn ordered(self: Selection, s: *const Screen, desired: Order) Selection {
- if (self.order(s) == desired) return Selection.init(
+ if (self.order(s) == desired) return .init(
self.start(),
self.end(),
self.rectangle,
@@ -237,9 +237,9 @@ pub fn ordered(self: Selection, s: *const Screen, desired: Order) Selection {
const tl = self.topLeft(s);
const br = self.bottomRight(s);
return switch (desired) {
- .forward => Selection.init(tl, br, self.rectangle),
- .reverse => Selection.init(br, tl, self.rectangle),
- else => Selection.init(tl, br, self.rectangle),
+ .forward => .init(tl, br, self.rectangle),
+ .reverse => .init(br, tl, self.rectangle),
+ else => .init(tl, br, self.rectangle),
};
}
diff --git a/src/terminal/StringMap.zig b/src/terminal/StringMap.zig
index 9892c13df..dde69d25e 100644
--- a/src/terminal/StringMap.zig
+++ b/src/terminal/StringMap.zig
@@ -80,7 +80,7 @@ pub const Match = struct {
const end_idx: usize = @intCast(self.region.ends()[0] - 1);
const start_pt = self.map.map[self.offset + start_idx];
const end_pt = self.map.map[self.offset + end_idx];
- return Selection.init(start_pt, end_pt, false);
+ return .init(start_pt, end_pt, false);
}
};
diff --git a/src/terminal/Tabstops.zig b/src/terminal/Tabstops.zig
index 5a54fb28b..4ab5133d9 100644
--- a/src/terminal/Tabstops.zig
+++ b/src/terminal/Tabstops.zig
@@ -44,7 +44,7 @@ const masks = blk: {
cols: usize = 0,
/// Preallocated tab stops.
-prealloc_stops: [prealloc_count]Unit = [1]Unit{0} ** prealloc_count,
+prealloc_stops: [prealloc_count]Unit = @splat(0),
/// Dynamically expanded stops above prealloc stops.
dynamic_stops: []Unit = &[0]Unit{},
diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig
index efb9684eb..be7a58f9b 100644
--- a/src/terminal/Terminal.zig
+++ b/src/terminal/Terminal.zig
@@ -79,7 +79,7 @@ default_palette: color.Palette = color.default,
color_palette: struct {
const Mask = std.StaticBitSet(@typeInfo(color.Palette).array.len);
colors: color.Palette = color.default,
- mask: Mask = Mask.initEmpty(),
+ mask: Mask = .initEmpty(),
} = .{},
/// The previous printed character. This is used for the repeat previous
@@ -210,9 +210,9 @@ pub fn init(
.cols = cols,
.rows = rows,
.active_screen = .primary,
- .screen = try Screen.init(alloc, cols, rows, opts.max_scrollback),
- .secondary_screen = try Screen.init(alloc, cols, rows, 0),
- .tabstops = try Tabstops.init(alloc, cols, TABSTOP_INTERVAL),
+ .screen = try .init(alloc, cols, rows, opts.max_scrollback),
+ .secondary_screen = try .init(alloc, cols, rows, 0),
+ .tabstops = try .init(alloc, cols, TABSTOP_INTERVAL),
.scrolling_region = .{
.top = 0,
.bottom = rows - 1,
@@ -2329,7 +2329,7 @@ pub fn printAttributes(self: *Terminal, buf: []u8) ![]const u8 {
try writer.writeByte('0');
const pen = self.screen.cursor.style;
- var attrs = [_]u8{0} ** 8;
+ var attrs: [8]u8 = @splat(0);
var i: usize = 0;
if (pen.flags.bold) {
@@ -2454,7 +2454,7 @@ pub fn resize(
// Resize our tabstops
if (self.cols != cols) {
self.tabstops.deinit(alloc);
- self.tabstops = try Tabstops.init(alloc, cols, 8);
+ self.tabstops = try .init(alloc, cols, 8);
}
// If we're making the screen smaller, dealloc the unused items.
@@ -2515,39 +2515,37 @@ pub fn getScreen(self: *Terminal, t: ScreenType) *Screen {
&self.secondary_screen;
}
-/// Options for switching to the alternate screen.
-pub const AlternateScreenOptions = struct {
- cursor_save: bool = false,
- clear_on_enter: bool = false,
- clear_on_exit: bool = false,
-};
-
-/// Switch to the alternate screen buffer.
+/// Switch to the given screen type (alternate or primary).
///
-/// The alternate screen buffer:
-/// * has its own grid
-/// * has its own cursor state (included saved cursor)
-/// * does not support scrollback
+/// This does NOT handle behaviors such as clearing the screen,
+/// copying the cursor, etc. This should be handled by downstream
+/// callers.
///
-pub fn alternateScreen(
- self: *Terminal,
- options: AlternateScreenOptions,
-) void {
- //log.info("alt screen active={} options={} cursor={}", .{ self.active_screen, options, self.screen.cursor });
-
- // TODO: test
- // TODO(mitchellh): what happens if we enter alternate screen multiple times?
- // for now, we ignore...
- if (self.active_screen == .alternate) return;
-
- // If we requested cursor save, we save the cursor in the primary screen
- if (options.cursor_save) self.saveCursor();
+/// After calling this function, the `self.screen` field will point
+/// to the current screen, and the returned value will be the previous
+/// screen. If the return value is null, then the screen was not
+/// switched because it was already the active screen.
+///
+/// Note: This is written in a generic way so that we can support
+/// more than two screens in the future if needed. There isn't
+/// currently a spec for this, but it is something I think might
+/// be useful in the future.
+pub fn switchScreen(self: *Terminal, t: ScreenType) ?*Screen {
+ // If we're already on the requested screen we do nothing.
+ if (self.active_screen == t) return null;
+
+ // We always end hyperlink state when switching screens.
+ // We need to do this on the original screen.
+ self.screen.endHyperlink();
// Switch the screens
const old = self.screen;
self.screen = self.secondary_screen;
self.secondary_screen = old;
- self.active_screen = .alternate;
+ self.active_screen = t;
+
+ // The new screen should not have any hyperlinks set
+ assert(self.screen.cursor.hyperlink_id == 0);
// Bring our charset state with us
self.screen.charset = old.charset;
@@ -2555,62 +2553,122 @@ pub fn alternateScreen(
// Clear our selection
self.screen.clearSelection();
- // Mark kitty images as dirty so they redraw
+ // Mark kitty images as dirty so they redraw. Without this set
+ // the images will remain where they were (the dirty bit on
+ // the screen only tracks the terminal grid, not the images).
self.screen.kitty_images.dirty = true;
- // Mark our terminal as dirty
+ // Mark our terminal as dirty to redraw the grid.
self.flags.dirty.clear = true;
- // Bring our pen with us
- self.screen.cursorCopy(old.cursor, .{
- .hyperlink = false,
- }) catch |err| {
- log.warn("cursor copy failed entering alt screen err={}", .{err});
- };
-
- if (options.clear_on_enter) {
- self.eraseDisplay(.complete, false);
- }
+ return &self.secondary_screen;
}
-/// Switch back to the primary screen (reset alternate screen mode).
-pub fn primaryScreen(
+/// Switch screen via a mode switch (e.g. mode 47, 1047, 1049).
+/// This is a much more opinionated operation than `switchScreen`
+/// since it also handles the behaviors of the specific mode,
+/// such as clearing the screen, saving/restoring the cursor,
+/// etc.
+///
+/// This should be used for legacy compatibility with VT protocols,
+/// but more modern usage should use `switchScreen` instead and handle
+/// details like clearing the screen, cursor saving, etc. manually.
+pub fn switchScreenMode(
self: *Terminal,
- options: AlternateScreenOptions,
+ mode: SwitchScreenMode,
+ enabled: bool,
) void {
- //log.info("primary screen active={} options={}", .{ self.active_screen, options });
+ // The behavior in this function is completely based on reading
+ // the xterm source, specifically "charproc.c" for
+ // `srm_ALTBUF`, `srm_OPT_ALTBUF`, and `srm_OPT_ALTBUF_CURSOR`.
+ // We shouldn't touch anything in here without adding a unit
+ // test AND verifying the behavior with xterm.
- // TODO: test
- // TODO(mitchellh): what happens if we enter alternate screen multiple times?
- if (self.active_screen == .primary) return;
+ switch (mode) {
+ .@"47" => {},
- if (options.clear_on_exit) self.eraseDisplay(.complete, false);
+ // If we're disabling 1047 and we're on alt screen then
+ // we clear the screen.
+ .@"1047" => if (!enabled and self.active_screen == .alternate) {
+ self.eraseDisplay(.complete, false);
+ },
- // Switch the screens
- const old = self.screen;
- self.screen = self.secondary_screen;
- self.secondary_screen = old;
- self.active_screen = .primary;
+ // 1049 unconditionally saves the cursor on enabling, even
+ // if we're already on the alternate screen.
+ .@"1049" => if (enabled) self.saveCursor(),
+ }
- // Clear our selection
- self.screen.clearSelection();
+ // Switch screens first to whatever we're going to.
+ const to: ScreenType = if (enabled) .alternate else .primary;
+ const old_ = self.switchScreen(to);
- // Mark kitty images as dirty so they redraw
- self.screen.kitty_images.dirty = true;
+ switch (mode) {
+ // For these modes, we need to copy the cursor. We only copy
+ // the cursor if the screen actually changed, otherwise the
+ // cursor is already copied. The cursor is copied regardless
+ // of destination screen.
+ .@"47", .@"1047" => if (old_) |old| {
+ self.screen.cursorCopy(old.cursor, .{
+ .hyperlink = false,
+ }) catch |err| {
+ log.warn(
+ "cursor copy failed entering alt screen err={}",
+ .{err},
+ );
+ };
+ },
- // Mark our terminal as dirty
- self.flags.dirty.clear = true;
+ // Mode 1049 restores cursor on the primary screen when
+ // we disable it.
+ .@"1049" => if (enabled) {
+ assert(self.active_screen == .alternate);
+ self.eraseDisplay(.complete, false);
+
+ // When we enter alt screen with 1049, we always copy the
+ // cursor from the primary screen (if we weren't already
+ // on it).
+ if (old_) |old| {
+ self.screen.cursorCopy(old.cursor, .{
+ .hyperlink = false,
+ }) catch |err| {
+ log.warn(
+ "cursor copy failed entering alt screen err={}",
+ .{err},
+ );
+ };
+ }
+ } else {
+ assert(self.active_screen == .primary);
+ self.restoreCursor() catch |err| {
+ log.warn(
+ "restore cursor on switch screen failed to={} err={}",
+ .{ to, err },
+ );
+ };
+ },
+ }
+}
- // We always end hyperlink state
- self.screen.endHyperlink();
+/// Modal screen changes. These map to the literal terminal
+/// modes to enable or disable alternate screen modes. They each
+/// have subtle behaviors so we define them as an enum here.
+pub const SwitchScreenMode = enum {
+ /// Legacy alternate screen mode. This goes to the alternate
+ /// screen or primary screen and only copies the cursor. The
+ /// screen is not erased.
+ @"47",
- // Restore the cursor from the primary screen. This should not
- // fail because we should not have to allocate memory since swapping
- // screens does not create new cursors.
- if (options.cursor_save) self.restoreCursor() catch |err| {
- log.warn("restore cursor on primary screen failed err={}", .{err});
- };
-}
+ /// Alternate screen mode where the alternate screen is cleared
+ /// on exit. The primary screen is never cleared. The cursor is
+ /// copied.
+ @"1047",
+
+ /// Save primary screen cursor, switch to alternate screen,
+ /// and clear the alternate screen on entry. On exit,
+ /// do not clear the screen, and restore the cursor on the
+ /// primary screen.
+ @"1049",
+};
/// Return the current string value of the terminal. Newlines are
/// encoded as "\n". This omits any formatting such as fg/bg.
@@ -9203,37 +9261,6 @@ test "Terminal: saveCursor" {
try testing.expect(t.modes.get(.origin));
}
-test "Terminal: saveCursor with screen change" {
- const alloc = testing.allocator;
- var t = try init(alloc, .{ .cols = 3, .rows = 3 });
- defer t.deinit(alloc);
-
- try t.setAttribute(.{ .bold = {} });
- t.setCursorPos(t.screen.cursor.y + 1, 3);
- try testing.expect(t.screen.cursor.x == 2);
- t.screen.charset.gr = .G3;
- t.modes.set(.origin, true);
- t.alternateScreen(.{
- .cursor_save = true,
- .clear_on_enter = true,
- });
- // make sure our cursor and charset have come with us
- try testing.expect(t.screen.cursor.style.flags.bold);
- try testing.expect(t.screen.cursor.x == 2);
- try testing.expect(t.screen.charset.gr == .G3);
- try testing.expect(t.modes.get(.origin));
- t.screen.charset.gr = .G0;
- try t.setAttribute(.{ .reset_bold = {} });
- t.modes.set(.origin, false);
- t.primaryScreen(.{
- .cursor_save = true,
- .clear_on_enter = true,
- });
- try testing.expect(t.screen.cursor.style.flags.bold);
- try testing.expect(t.screen.charset.gr == .G3);
- try testing.expect(t.modes.get(.origin));
-}
-
test "Terminal: saveCursor position" {
const alloc = testing.allocator;
var t = try init(alloc, .{ .cols = 10, .rows = 5 });
@@ -10472,7 +10499,7 @@ test "Terminal: cursorIsAtPrompt alternate screen" {
try testing.expect(t.cursorIsAtPrompt());
// Secondary screen is never a prompt
- t.alternateScreen(.{});
+ t.switchScreenMode(.@"1049", true);
try testing.expect(!t.cursorIsAtPrompt());
t.markSemanticPrompt(.prompt);
try testing.expect(!t.cursorIsAtPrompt());
@@ -10556,7 +10583,7 @@ test "Terminal: fullReset clears alt screen kitty keyboard state" {
var t = try init(testing.allocator, .{ .cols = 10, .rows = 10 });
defer t.deinit(testing.allocator);
- t.alternateScreen(.{});
+ t.switchScreenMode(.@"1049", true);
t.screen.kitty_keyboard.push(.{
.disambiguate = true,
.report_events = false,
@@ -10564,7 +10591,7 @@ test "Terminal: fullReset clears alt screen kitty keyboard state" {
.report_all = true,
.report_associated = true,
});
- t.primaryScreen(.{});
+ t.switchScreenMode(.@"1049", false);
t.fullReset();
try testing.expectEqual(0, t.secondary_screen.kitty_keyboard.current().int());
@@ -10869,3 +10896,236 @@ test "Terminal: DECCOLM resets scroll region" {
try testing.expectEqual(@as(usize, 0), t.scrolling_region.left);
try testing.expectEqual(@as(usize, 79), t.scrolling_region.right);
}
+
+test "Terminal: mode 47 alt screen plain" {
+ const alloc = testing.allocator;
+ var t = try init(alloc, .{ .rows = 5, .cols = 5 });
+ defer t.deinit(alloc);
+
+ // Print on primary screen
+ try t.printString("1A");
+
+ // Go to alt screen with mode 47
+ t.switchScreenMode(.@"47", true);
+ try testing.expectEqual(ScreenType.alternate, t.active_screen);
+
+ // Screen should be empty
+ {
+ const str = try t.plainString(testing.allocator);
+ defer testing.allocator.free(str);
+ try testing.expectEqualStrings("", str);
+ }
+
+ // Print on alt screen. This should be off center because
+ // we copy the cursor over from the primary screen
+ try t.printString("2B");
+ {
+ const str = try t.plainString(testing.allocator);
+ defer testing.allocator.free(str);
+ try testing.expectEqualStrings(" 2B", str);
+ }
+
+ // Go back to primary
+ t.switchScreenMode(.@"47", false);
+ try testing.expectEqual(ScreenType.primary, t.active_screen);
+
+ // Primary screen should still have the original content
+ {
+ const str = try t.plainString(testing.allocator);
+ defer testing.allocator.free(str);
+ try testing.expectEqualStrings("1A", str);
+ }
+
+ // Go back to alt screen with mode 47
+ t.switchScreenMode(.@"47", true);
+ try testing.expectEqual(ScreenType.alternate, t.active_screen);
+
+ // Screen should retain content
+ {
+ const str = try t.plainString(testing.allocator);
+ defer testing.allocator.free(str);
+ try testing.expectEqualStrings(" 2B", str);
+ }
+}
+
+test "Terminal: mode 47 copies cursor both directions" {
+ const alloc = testing.allocator;
+ var t = try init(alloc, .{ .rows = 5, .cols = 5 });
+ defer t.deinit(alloc);
+
+ // Color our cursor red
+ try t.setAttribute(.{ .direct_color_fg = .{ .r = 0xFF, .g = 0, .b = 0x7F } });
+
+ // Go to alt screen with mode 47
+ t.switchScreenMode(.@"47", true);
+ try testing.expectEqual(ScreenType.alternate, t.active_screen);
+
+ // Verify that our style is set
+ {
+ try testing.expect(t.screen.cursor.style_id != style.default_id);
+ const page = &t.screen.cursor.page_pin.node.data;
+ try testing.expectEqual(@as(usize, 1), page.styles.count());
+ try testing.expect(page.styles.refCount(page.memory, t.screen.cursor.style_id) > 0);
+ }
+
+ // Set a new style
+ try t.setAttribute(.{ .direct_color_fg = .{ .r = 0, .g = 0xFF, .b = 0 } });
+
+ // Go back to primary
+ t.switchScreenMode(.@"47", false);
+ try testing.expectEqual(ScreenType.primary, t.active_screen);
+
+ // Verify that our style is still set
+ {
+ try testing.expect(t.screen.cursor.style_id != style.default_id);
+ const page = &t.screen.cursor.page_pin.node.data;
+ try testing.expectEqual(@as(usize, 1), page.styles.count());
+ try testing.expect(page.styles.refCount(page.memory, t.screen.cursor.style_id) > 0);
+ }
+}
+
+test "Terminal: mode 1047 alt screen plain" {
+ const alloc = testing.allocator;
+ var t = try init(alloc, .{ .rows = 5, .cols = 5 });
+ defer t.deinit(alloc);
+
+ // Print on primary screen
+ try t.printString("1A");
+
+ // Go to alt screen with mode 47
+ t.switchScreenMode(.@"1047", true);
+ try testing.expectEqual(ScreenType.alternate, t.active_screen);
+
+ // Screen should be empty
+ {
+ const str = try t.plainString(testing.allocator);
+ defer testing.allocator.free(str);
+ try testing.expectEqualStrings("", str);
+ }
+
+ // Print on alt screen. This should be off center because
+ // we copy the cursor over from the primary screen
+ try t.printString("2B");
+ {
+ const str = try t.plainString(testing.allocator);
+ defer testing.allocator.free(str);
+ try testing.expectEqualStrings(" 2B", str);
+ }
+
+ // Go back to primary
+ t.switchScreenMode(.@"1047", false);
+ try testing.expectEqual(ScreenType.primary, t.active_screen);
+
+ // Primary screen should still have the original content
+ {
+ const str = try t.plainString(testing.allocator);
+ defer testing.allocator.free(str);
+ try testing.expectEqualStrings("1A", str);
+ }
+
+ // Go back to alt screen with mode 1047
+ t.switchScreenMode(.@"1047", true);
+ try testing.expectEqual(ScreenType.alternate, t.active_screen);
+
+ // Screen should be empty
+ {
+ const str = try t.plainString(testing.allocator);
+ defer testing.allocator.free(str);
+ try testing.expectEqualStrings("", str);
+ }
+}
+
+test "Terminal: mode 1047 copies cursor both directions" {
+ const alloc = testing.allocator;
+ var t = try init(alloc, .{ .rows = 5, .cols = 5 });
+ defer t.deinit(alloc);
+
+ // Color our cursor red
+ try t.setAttribute(.{ .direct_color_fg = .{ .r = 0xFF, .g = 0, .b = 0x7F } });
+
+ // Go to alt screen with mode 47
+ t.switchScreenMode(.@"1047", true);
+ try testing.expectEqual(ScreenType.alternate, t.active_screen);
+
+ // Verify that our style is set
+ {
+ try testing.expect(t.screen.cursor.style_id != style.default_id);
+ const page = &t.screen.cursor.page_pin.node.data;
+ try testing.expectEqual(@as(usize, 1), page.styles.count());
+ try testing.expect(page.styles.refCount(page.memory, t.screen.cursor.style_id) > 0);
+ }
+
+ // Set a new style
+ try t.setAttribute(.{ .direct_color_fg = .{ .r = 0, .g = 0xFF, .b = 0 } });
+
+ // Go back to primary
+ t.switchScreenMode(.@"1047", false);
+ try testing.expectEqual(ScreenType.primary, t.active_screen);
+
+ // Verify that our style is still set
+ {
+ try testing.expect(t.screen.cursor.style_id != style.default_id);
+ const page = &t.screen.cursor.page_pin.node.data;
+ try testing.expectEqual(@as(usize, 1), page.styles.count());
+ try testing.expect(page.styles.refCount(page.memory, t.screen.cursor.style_id) > 0);
+ }
+}
+
+test "Terminal: mode 1049 alt screen plain" {
+ const alloc = testing.allocator;
+ var t = try init(alloc, .{ .rows = 5, .cols = 5 });
+ defer t.deinit(alloc);
+
+ // Print on primary screen
+ try t.printString("1A");
+
+ // Go to alt screen with mode 47
+ t.switchScreenMode(.@"1049", true);
+ try testing.expectEqual(ScreenType.alternate, t.active_screen);
+
+ // Screen should be empty
+ {
+ const str = try t.plainString(testing.allocator);
+ defer testing.allocator.free(str);
+ try testing.expectEqualStrings("", str);
+ }
+
+ // Print on alt screen. This should be off center because
+ // we copy the cursor over from the primary screen
+ try t.printString("2B");
+ {
+ const str = try t.plainString(testing.allocator);
+ defer testing.allocator.free(str);
+ try testing.expectEqualStrings(" 2B", str);
+ }
+
+ // Go back to primary
+ t.switchScreenMode(.@"1049", false);
+ try testing.expectEqual(ScreenType.primary, t.active_screen);
+
+ // Primary screen should still have the original content
+ {
+ const str = try t.plainString(testing.allocator);
+ defer testing.allocator.free(str);
+ try testing.expectEqualStrings("1A", str);
+ }
+
+ // Write, our cursor should be restored back.
+ try t.printString("C");
+ {
+ const str = try t.plainString(testing.allocator);
+ defer testing.allocator.free(str);
+ try testing.expectEqualStrings("1AC", str);
+ }
+
+ // Go back to alt screen with mode 1049
+ t.switchScreenMode(.@"1049", true);
+ try testing.expectEqual(ScreenType.alternate, t.active_screen);
+
+ // Screen should be empty
+ {
+ const str = try t.plainString(testing.allocator);
+ defer testing.allocator.free(str);
+ try testing.expectEqualStrings("", str);
+ }
+}
diff --git a/src/terminal/bitmap_allocator.zig b/src/terminal/bitmap_allocator.zig
index f96d39831..68d968768 100644
--- a/src/terminal/bitmap_allocator.zig
+++ b/src/terminal/bitmap_allocator.zig
@@ -403,7 +403,7 @@ test "BitmapAllocator alloc sequentially" {
const buf = try alloc.alignedAlloc(u8, Alloc.base_align, layout.total_size);
defer alloc.free(buf);
- var bm = Alloc.init(OffsetBuf.init(buf), layout);
+ var bm = Alloc.init(.init(buf), layout);
const ptr = try bm.alloc(u8, buf, 1);
ptr[0] = 'A';
@@ -429,7 +429,7 @@ test "BitmapAllocator alloc non-byte" {
const buf = try alloc.alignedAlloc(u8, Alloc.base_align, layout.total_size);
defer alloc.free(buf);
- var bm = Alloc.init(OffsetBuf.init(buf), layout);
+ var bm = Alloc.init(.init(buf), layout);
const ptr = try bm.alloc(u21, buf, 1);
ptr[0] = 'A';
@@ -453,7 +453,7 @@ test "BitmapAllocator alloc non-byte multi-chunk" {
const buf = try alloc.alignedAlloc(u8, Alloc.base_align, layout.total_size);
defer alloc.free(buf);
- var bm = Alloc.init(OffsetBuf.init(buf), layout);
+ var bm = Alloc.init(.init(buf), layout);
const ptr = try bm.alloc(u21, buf, 6);
try testing.expectEqual(@as(usize, 6), ptr.len);
for (ptr) |*v| v.* = 'A';
@@ -478,7 +478,7 @@ test "BitmapAllocator alloc large" {
const buf = try alloc.alignedAlloc(u8, Alloc.base_align, layout.total_size);
defer alloc.free(buf);
- var bm = Alloc.init(OffsetBuf.init(buf), layout);
+ var bm = Alloc.init(.init(buf), layout);
const ptr = try bm.alloc(u8, buf, 129);
ptr[0] = 'A';
bm.free(buf, ptr);
diff --git a/src/terminal/hash_map.zig b/src/terminal/hash_map.zig
index 0cc17a747..9a16be3b2 100644
--- a/src/terminal/hash_map.zig
+++ b/src/terminal/hash_map.zig
@@ -893,7 +893,7 @@ test "HashMap basic usage" {
const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size);
defer alloc.free(buf);
- var map = Map.init(OffsetBuf.init(buf), layout);
+ var map = Map.init(.init(buf), layout);
const count = 5;
var i: u32 = 0;
@@ -927,7 +927,7 @@ test "HashMap ensureTotalCapacity" {
const layout = Map.layoutForCapacity(cap);
const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size);
defer alloc.free(buf);
- var map = Map.init(OffsetBuf.init(buf), layout);
+ var map = Map.init(.init(buf), layout);
const initial_capacity = map.capacity();
try testing.expect(initial_capacity >= 20);
@@ -947,7 +947,7 @@ test "HashMap ensureUnusedCapacity with tombstones" {
const layout = Map.layoutForCapacity(cap);
const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size);
defer alloc.free(buf);
- var map = Map.init(OffsetBuf.init(buf), layout);
+ var map = Map.init(.init(buf), layout);
var i: i32 = 0;
while (i < 100) : (i += 1) {
@@ -965,7 +965,7 @@ test "HashMap clearRetainingCapacity" {
const layout = Map.layoutForCapacity(cap);
const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size);
defer alloc.free(buf);
- var map = Map.init(OffsetBuf.init(buf), layout);
+ var map = Map.init(.init(buf), layout);
map.clearRetainingCapacity();
@@ -996,7 +996,7 @@ test "HashMap ensureTotalCapacity with existing elements" {
const layout = Map.layoutForCapacity(cap);
const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size);
defer alloc.free(buf);
- var map = Map.init(OffsetBuf.init(buf), layout);
+ var map = Map.init(.init(buf), layout);
try map.put(0, 0);
try expectEqual(map.count(), 1);
@@ -1015,7 +1015,7 @@ test "HashMap remove" {
const layout = Map.layoutForCapacity(cap);
const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size);
defer alloc.free(buf);
- var map = Map.init(OffsetBuf.init(buf), layout);
+ var map = Map.init(.init(buf), layout);
var i: u32 = 0;
while (i < 16) : (i += 1) {
@@ -1053,7 +1053,7 @@ test "HashMap reverse removes" {
const layout = Map.layoutForCapacity(cap);
const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size);
defer alloc.free(buf);
- var map = Map.init(OffsetBuf.init(buf), layout);
+ var map = Map.init(.init(buf), layout);
var i: u32 = 0;
while (i < 16) : (i += 1) {
@@ -1081,7 +1081,7 @@ test "HashMap multiple removes on same metadata" {
const layout = Map.layoutForCapacity(cap);
const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size);
defer alloc.free(buf);
- var map = Map.init(OffsetBuf.init(buf), layout);
+ var map = Map.init(.init(buf), layout);
var i: u32 = 0;
while (i < 16) : (i += 1) {
@@ -1124,7 +1124,7 @@ test "HashMap put and remove loop in random order" {
const layout = Map.layoutForCapacity(cap);
const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size);
defer alloc.free(buf);
- var map = Map.init(OffsetBuf.init(buf), layout);
+ var map = Map.init(.init(buf), layout);
var keys = std.ArrayList(u32).init(alloc);
defer keys.deinit();
@@ -1162,7 +1162,7 @@ test "HashMap put" {
const layout = Map.layoutForCapacity(cap);
const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size);
defer alloc.free(buf);
- var map = Map.init(OffsetBuf.init(buf), layout);
+ var map = Map.init(.init(buf), layout);
var i: u32 = 0;
while (i < 16) : (i += 1) {
@@ -1193,7 +1193,7 @@ test "HashMap put full load" {
const layout = Map.layoutForCapacity(cap);
const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size);
defer alloc.free(buf);
- var map = Map.init(OffsetBuf.init(buf), layout);
+ var map = Map.init(.init(buf), layout);
for (0..cap) |i| try map.put(i, i);
for (0..cap) |i| try expectEqual(map.get(i).?, i);
@@ -1209,7 +1209,7 @@ test "HashMap putAssumeCapacity" {
const layout = Map.layoutForCapacity(cap);
const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size);
defer alloc.free(buf);
- var map = Map.init(OffsetBuf.init(buf), layout);
+ var map = Map.init(.init(buf), layout);
var i: u32 = 0;
while (i < 20) : (i += 1) {
@@ -1244,7 +1244,7 @@ test "HashMap repeat putAssumeCapacity/remove" {
const layout = Map.layoutForCapacity(cap);
const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size);
defer alloc.free(buf);
- var map = Map.init(OffsetBuf.init(buf), layout);
+ var map = Map.init(.init(buf), layout);
const limit = cap;
@@ -1280,7 +1280,7 @@ test "HashMap getOrPut" {
const layout = Map.layoutForCapacity(cap);
const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size);
defer alloc.free(buf);
- var map = Map.init(OffsetBuf.init(buf), layout);
+ var map = Map.init(.init(buf), layout);
var i: u32 = 0;
while (i < 10) : (i += 1) {
@@ -1309,7 +1309,7 @@ test "HashMap basic hash map usage" {
const layout = Map.layoutForCapacity(cap);
const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size);
defer alloc.free(buf);
- var map = Map.init(OffsetBuf.init(buf), layout);
+ var map = Map.init(.init(buf), layout);
try testing.expect((try map.fetchPut(1, 11)) == null);
try testing.expect((try map.fetchPut(2, 22)) == null);
@@ -1360,7 +1360,7 @@ test "HashMap ensureUnusedCapacity" {
const layout = Map.layoutForCapacity(cap);
const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size);
defer alloc.free(buf);
- var map = Map.init(OffsetBuf.init(buf), layout);
+ var map = Map.init(.init(buf), layout);
try map.ensureUnusedCapacity(32);
try testing.expectError(error.OutOfMemory, map.ensureUnusedCapacity(cap + 1));
@@ -1374,7 +1374,7 @@ test "HashMap removeByPtr" {
const layout = Map.layoutForCapacity(cap);
const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size);
defer alloc.free(buf);
- var map = Map.init(OffsetBuf.init(buf), layout);
+ var map = Map.init(.init(buf), layout);
var i: i32 = undefined;
i = 0;
@@ -1405,7 +1405,7 @@ test "HashMap removeByPtr 0 sized key" {
const layout = Map.layoutForCapacity(cap);
const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size);
defer alloc.free(buf);
- var map = Map.init(OffsetBuf.init(buf), layout);
+ var map = Map.init(.init(buf), layout);
try map.put(0, 0);
@@ -1429,7 +1429,7 @@ test "HashMap repeat fetchRemove" {
const layout = Map.layoutForCapacity(cap);
const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size);
defer alloc.free(buf);
- var map = Map.init(OffsetBuf.init(buf), layout);
+ var map = Map.init(.init(buf), layout);
map.putAssumeCapacity(0, {});
map.putAssumeCapacity(1, {});
@@ -1457,7 +1457,7 @@ test "OffsetHashMap basic usage" {
const layout = OffsetMap.layout(cap);
const buf = try alloc.alignedAlloc(u8, OffsetMap.base_align, layout.total_size);
defer alloc.free(buf);
- var offset_map = OffsetMap.init(OffsetBuf.init(buf), layout);
+ var offset_map = OffsetMap.init(.init(buf), layout);
var map = offset_map.map(buf.ptr);
const count = 5;
@@ -1492,7 +1492,7 @@ test "OffsetHashMap remake map" {
const layout = OffsetMap.layout(cap);
const buf = try alloc.alignedAlloc(u8, OffsetMap.base_align, layout.total_size);
defer alloc.free(buf);
- var offset_map = OffsetMap.init(OffsetBuf.init(buf), layout);
+ var offset_map = OffsetMap.init(.init(buf), layout);
{
var map = offset_map.map(buf.ptr);
diff --git a/src/terminal/kitty/graphics_command.zig b/src/terminal/kitty/graphics_command.zig
index 61ba33a4d..adc6edafe 100644
--- a/src/terminal/kitty/graphics_command.zig
+++ b/src/terminal/kitty/graphics_command.zig
@@ -155,17 +155,17 @@ pub const Parser = struct {
break :action c;
};
const control: Command.Control = switch (action) {
- 'q' => .{ .query = try Transmission.parse(self.kv) },
- 't' => .{ .transmit = try Transmission.parse(self.kv) },
+ 'q' => .{ .query = try .parse(self.kv) },
+ 't' => .{ .transmit = try .parse(self.kv) },
'T' => .{ .transmit_and_display = .{
- .transmission = try Transmission.parse(self.kv),
- .display = try Display.parse(self.kv),
+ .transmission = try .parse(self.kv),
+ .display = try .parse(self.kv),
} },
- 'p' => .{ .display = try Display.parse(self.kv) },
- 'd' => .{ .delete = try Delete.parse(self.kv) },
- 'f' => .{ .transmit_animation_frame = try AnimationFrameLoading.parse(self.kv) },
- 'a' => .{ .control_animation = try AnimationControl.parse(self.kv) },
- 'c' => .{ .compose_animation = try AnimationFrameComposition.parse(self.kv) },
+ 'p' => .{ .display = try .parse(self.kv) },
+ 'd' => .{ .delete = try .parse(self.kv) },
+ 'f' => .{ .transmit_animation_frame = try .parse(self.kv) },
+ 'a' => .{ .control_animation = try .parse(self.kv) },
+ 'c' => .{ .compose_animation = try .parse(self.kv) },
else => return error.InvalidFormat,
};
diff --git a/src/terminal/kitty/graphics_exec.zig b/src/terminal/kitty/graphics_exec.zig
index 25c819b10..f917c104a 100644
--- a/src/terminal/kitty/graphics_exec.zig
+++ b/src/terminal/kitty/graphics_exec.zig
@@ -324,7 +324,7 @@ fn loadAndAddImage(
}
break :loading loading.*;
- } else try LoadingImage.init(alloc, cmd);
+ } else try .init(alloc, cmd);
// We only want to deinit on error. If we're chunking, then we don't
// want to deinit at all. If we're not chunking, then we'll deinit
diff --git a/src/terminal/kitty/key.zig b/src/terminal/kitty/key.zig
index 8bafcb7dc..0883c90f2 100644
--- a/src/terminal/kitty/key.zig
+++ b/src/terminal/kitty/key.zig
@@ -8,7 +8,7 @@ const std = @import("std");
pub const FlagStack = struct {
const len = 8;
- flags: [len]Flags = .{Flags{}} ** len,
+ flags: [len]Flags = @splat(.{}),
idx: u3 = 0,
/// Return the current stack value
@@ -51,7 +51,7 @@ pub const FlagStack = struct {
// could send a huge number of pop commands to waste cpu.
if (n >= self.flags.len) {
self.idx = 0;
- self.flags = .{Flags{}} ** len;
+ self.flags = @splat(.{});
return;
}
diff --git a/src/terminal/main.zig b/src/terminal/main.zig
index df3788d30..74ffe6341 100644
--- a/src/terminal/main.zig
+++ b/src/terminal/main.zig
@@ -35,6 +35,7 @@ pub const Page = page.Page;
pub const PageList = @import("PageList.zig");
pub const Parser = @import("Parser.zig");
pub const Pin = PageList.Pin;
+pub const Point = point.Point;
pub const Screen = @import("Screen.zig");
pub const ScreenType = Terminal.ScreenType;
pub const Selection = @import("Selection.zig");
diff --git a/src/terminal/modes.zig b/src/terminal/modes.zig
index 60ecc7698..9a74db73c 100644
--- a/src/terminal/modes.zig
+++ b/src/terminal/modes.zig
@@ -206,6 +206,7 @@ const entries: []const ModeEntry = &.{
.{ .name = "cursor_visible", .value = 25, .default = true },
.{ .name = "enable_mode_3", .value = 40 },
.{ .name = "reverse_wrap", .value = 45 },
+ .{ .name = "alt_screen_legacy", .value = 47 },
.{ .name = "keypad_keys", .value = 66 },
.{ .name = "enable_left_and_right_margin", .value = 69 },
.{ .name = "mouse_event_normal", .value = 1000 },
@@ -222,6 +223,7 @@ const entries: []const ModeEntry = &.{
.{ .name = "alt_sends_escape", .value = 1039 },
.{ .name = "reverse_wrap_extended", .value = 1045 },
.{ .name = "alt_screen", .value = 1047 },
+ .{ .name = "save_cursor", .value = 1048 },
.{ .name = "alt_screen_save_cursor_clear_enter", .value = 1049 },
.{ .name = "bracketed_paste", .value = 2004 },
.{ .name = "synchronized_output", .value = 2026 },
diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig
index ce7afdf64..d0b59e834 100644
--- a/src/terminal/osc.zig
+++ b/src/terminal/osc.zig
@@ -109,37 +109,21 @@ pub const Command = union(enum) {
value: []const u8,
},
- /// OSC 4, OSC 10, and OSC 11 color report.
- report_color: struct {
- /// OSC 4 requests a palette color, OSC 10 requests the foreground
- /// color, OSC 11 the background color.
- kind: ColorKind,
-
- /// We must reply with the same string terminator (ST) as used in the
- /// request.
+ /// OSC color operations to set, reset, or report color settings. Some OSCs
+ /// allow multiple operations to be specified in a single OSC so we need a
+ /// list-like datastructure to manage them. We use std.SegmentedList because
+ /// it minimizes the number of allocations and copies because a large
+ /// majority of the time there will be only one operation per OSC.
+ ///
+ /// Currently, these OSCs are handled by `color_operation`:
+ ///
+ /// 4, 10, 11, 12, 104, 110, 111, 112
+ color_operation: struct {
+ source: ColorOperation.Source,
+ operations: ColorOperation.List = .{},
terminator: Terminator = .st,
},
- /// Modify the foreground (OSC 10) or background color (OSC 11), or a palette color (OSC 4)
- set_color: struct {
- /// OSC 4 sets a palette color, OSC 10 sets the foreground color, OSC 11
- /// the background color.
- kind: ColorKind,
-
- /// The color spec as a string
- value: []const u8,
- },
-
- /// Reset a palette color (OSC 104) or the foreground (OSC 110), background
- /// (OSC 111), or cursor (OSC 112) color.
- reset_color: struct {
- kind: ColorKind,
-
- /// OSC 104 can have parameters indicating which palette colors to
- /// reset.
- value: []const u8,
- },
-
/// Kitty color protocol, OSC 21
/// https://sw.kovidgoyal.net/kitty/color-stack/#id1
kitty_color_protocol: kitty.color.OSC,
@@ -182,20 +166,44 @@ pub const Command = union(enum) {
/// Wait input (OSC 9;5)
wait_input: void,
- pub const ColorKind = union(enum) {
- palette: u8,
- foreground,
- background,
- cursor,
-
- pub fn code(self: ColorKind) []const u8 {
- return switch (self) {
- .palette => "4",
- .foreground => "10",
- .background => "11",
- .cursor => "12",
- };
- }
+ pub const ColorOperation = union(enum) {
+ pub const Source = enum(u16) {
+ // these numbers are based on the OSC operation code
+ // see https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Operating-System-Commands
+ get_set_palette = 4,
+ get_set_foreground = 10,
+ get_set_background = 11,
+ get_set_cursor = 12,
+ reset_palette = 104,
+ reset_foreground = 110,
+ reset_background = 111,
+ reset_cursor = 112,
+
+ pub fn format(
+ self: Source,
+ comptime _: []const u8,
+ options: std.fmt.FormatOptions,
+ writer: anytype,
+ ) !void {
+ try std.fmt.formatInt(@intFromEnum(self), 10, .lower, options, writer);
+ }
+ };
+
+ pub const List = std.SegmentedList(ColorOperation, 2);
+
+ pub const Kind = union(enum) {
+ palette: u8,
+ foreground,
+ background,
+ cursor,
+ };
+
+ set: struct {
+ kind: Kind,
+ color: RGB,
+ },
+ reset: Kind,
+ report: Kind,
};
pub const ProgressState = enum {
@@ -205,6 +213,15 @@ pub const Command = union(enum) {
indeterminate,
pause,
};
+
+ comptime {
+ assert(@sizeOf(Command) == switch (@sizeOf(usize)) {
+ 4 => 44,
+ 8 => 64,
+ else => unreachable,
+ });
+ // @compileLog(@sizeOf(Command));
+ }
};
/// The terminator used to end an OSC command. For OSC commands that demand
@@ -234,6 +251,15 @@ pub const Terminator = enum {
.bel => "\x07",
};
}
+
+ pub fn format(
+ self: Terminator,
+ comptime _: []const u8,
+ _: std.fmt.FormatOptions,
+ writer: anytype,
+ ) !void {
+ try writer.writeAll(self.string());
+ }
};
pub const Parser = struct {
@@ -288,6 +314,7 @@ pub const Parser = struct {
@"0",
@"1",
@"10",
+ @"104",
@"11",
@"12",
@"13",
@@ -304,15 +331,6 @@ pub const Parser = struct {
@"8",
@"9",
- // OSC 10 is used to query or set the current foreground color.
- query_fg_color,
-
- // OSC 11 is used to query or set the current background color.
- query_bg_color,
-
- // OSC 12 is used to query or set the current cursor color.
- query_cursor_color,
-
// We're in a semantic prompt OSC command but we aren't sure
// what the command is yet, i.e. `133;`
semantic_prompt,
@@ -327,17 +345,26 @@ pub const Parser = struct {
clipboard_kind_end,
// Get/set color palette index
- color_palette_index,
- color_palette_index_end,
+ osc_4_index,
+ osc_4_color,
+
+ // Get/set foreground color
+ osc_10,
+
+ // Get/set background color
+ osc_11,
+
+ // Get/set cursor color
+ osc_12,
+
+ // Reset color palette index
+ osc_104,
// Hyperlinks
hyperlink_param_key,
hyperlink_param_value,
hyperlink_uri,
- // Reset color palette index
- reset_color_palette_index,
-
// rxvt extension. Only used for OSC 777 and only the value "notify" is
// supported
rxvt_extension,
@@ -423,6 +450,10 @@ pub const Parser = struct {
v.list.deinit();
self.command = default;
},
+ .color_operation => |*v| {
+ v.operations.deinit(self.alloc.?);
+ self.command = default;
+ },
else => {},
}
}
@@ -502,41 +533,123 @@ pub const Parser = struct {
},
.@"10" => switch (c) {
- ';' => self.state = .query_fg_color,
- '4' => {
- self.command = .{ .reset_color = .{
- .kind = .{ .palette = 0 },
- .value = "",
- } };
-
- self.state = .reset_color_palette_index;
+ ';' => osc_10: {
+ if (self.alloc == null) {
+ log.warn("OSC 10 requires an allocator, but none was provided", .{});
+ self.state = .invalid;
+ break :osc_10;
+ }
+ self.command = .{
+ .color_operation = .{
+ .source = .get_set_foreground,
+ },
+ };
+ self.state = .osc_10;
+ self.buf_start = self.buf_idx;
self.complete = true;
},
+ '4' => self.state = .@"104",
else => self.state = .invalid,
},
- .@"11" => switch (c) {
- ';' => self.state = .query_bg_color,
- '0' => {
- self.command = .{ .reset_color = .{ .kind = .foreground, .value = undefined } };
+ .osc_10, .osc_11, .osc_12 => switch (c) {
+ ';' => self.parseOSC101112(false),
+ else => {},
+ },
+
+ .@"104" => switch (c) {
+ ';' => osc_104: {
+ if (self.alloc == null) {
+ log.warn("OSC 104 requires an allocator, but none was provided", .{});
+ self.state = .invalid;
+ break :osc_104;
+ }
+ self.command = .{
+ .color_operation = .{
+ .source = .reset_palette,
+ },
+ };
+ self.state = .osc_104;
+ self.buf_start = self.buf_idx;
self.complete = true;
- self.state = .invalid;
},
- '1' => {
- self.command = .{ .reset_color = .{ .kind = .background, .value = undefined } };
+ else => self.state = .invalid,
+ },
+
+ .osc_104 => switch (c) {
+ ';' => self.parseOSC104(false),
+ else => {},
+ },
+
+ .@"11" => switch (c) {
+ ';' => osc_11: {
+ if (self.alloc == null) {
+ log.warn("OSC 11 requires an allocator, but none was provided", .{});
+ self.state = .invalid;
+ break :osc_11;
+ }
+ self.command = .{
+ .color_operation = .{
+ .source = .get_set_background,
+ },
+ };
+ self.state = .osc_11;
+ self.buf_start = self.buf_idx;
self.complete = true;
- self.state = .invalid;
},
- '2' => {
- self.command = .{ .reset_color = .{ .kind = .cursor, .value = undefined } };
+ '0'...'2' => blk: {
+ if (self.alloc == null) {
+ log.warn("OSC 11{c} requires an allocator, but none was provided", .{c});
+ self.state = .invalid;
+ break :blk;
+ }
+
+ const alloc = self.alloc orelse return;
+
+ self.command = .{
+ .color_operation = .{
+ .source = switch (c) {
+ '0' => .reset_foreground,
+ '1' => .reset_background,
+ '2' => .reset_cursor,
+ else => unreachable,
+ },
+ },
+ };
+ const op = self.command.color_operation.operations.addOne(alloc) catch |err| {
+ log.warn("unable to append color operation: {}", .{err});
+ return;
+ };
+ op.* = .{
+ .reset = switch (c) {
+ '0' => .foreground,
+ '1' => .background,
+ '2' => .cursor,
+ else => unreachable,
+ },
+ };
+ self.state = .swallow;
self.complete = true;
- self.state = .invalid;
},
else => self.state = .invalid,
},
.@"12" => switch (c) {
- ';' => self.state = .query_cursor_color,
+ ';' => osc_12: {
+ if (self.alloc == null) {
+ log.warn("OSC 12 requires an allocator, but none was provided", .{});
+ self.state = .invalid;
+ break :osc_12;
+ }
+ self.command = .{
+ .color_operation = .{
+ .source = .get_set_cursor,
+ },
+ };
+ self.state = .osc_12;
+ self.buf_start = self.buf_idx;
+ self.complete = true;
+ },
else => self.state = .invalid,
},
@@ -621,64 +734,35 @@ pub const Parser = struct {
},
.@"4" => switch (c) {
- ';' => {
- self.state = .color_palette_index;
- self.buf_start = self.buf_idx;
- },
- else => self.state = .invalid,
- },
-
- .color_palette_index => switch (c) {
- '0'...'9' => {},
- ';' => blk: {
- const str = self.buf[self.buf_start .. self.buf_idx - 1];
- if (str.len == 0) {
+ ';' => osc_4: {
+ if (self.alloc == null) {
+ log.info("OSC 4 requires an allocator, but none was provided", .{});
self.state = .invalid;
- break :blk;
- }
-
- if (std.fmt.parseUnsigned(u8, str, 10)) |num| {
- self.state = .color_palette_index_end;
- self.temp_state = .{ .num = num };
- } else |err| switch (err) {
- error.Overflow => self.state = .invalid,
- error.InvalidCharacter => unreachable,
+ break :osc_4;
}
+ self.command = .{
+ .color_operation = .{
+ .source = .get_set_palette,
+ },
+ };
+ self.state = .osc_4_index;
+ self.buf_start = self.buf_idx;
+ self.complete = true;
},
else => self.state = .invalid,
},
- .color_palette_index_end => switch (c) {
- '?' => {
- self.command = .{ .report_color = .{
- .kind = .{ .palette = @intCast(self.temp_state.num) },
- } };
-
- self.complete = true;
- },
- else => {
- self.command = .{ .set_color = .{
- .kind = .{ .palette = @intCast(self.temp_state.num) },
- .value = "",
- } };
-
- self.state = .string;
- self.temp_state = .{ .str = &self.command.set_color.value };
- self.buf_start = self.buf_idx - 1;
- },
+ .osc_4_index => switch (c) {
+ ';' => self.state = .osc_4_color,
+ else => {},
},
- .reset_color_palette_index => switch (c) {
+ .osc_4_color => switch (c) {
';' => {
- self.state = .string;
- self.temp_state = .{ .str = &self.command.reset_color.value };
- self.buf_start = self.buf_idx;
- self.complete = false;
- },
- else => {
- self.state = .invalid;
- self.complete = false;
+ self.parseOSC4(false);
+ self.state = .osc_4_index;
},
+ else => {},
},
.@"5" => switch (c) {
@@ -969,60 +1053,6 @@ pub const Parser = struct {
},
},
- .query_fg_color => switch (c) {
- '?' => {
- self.command = .{ .report_color = .{ .kind = .foreground } };
- self.complete = true;
- self.state = .invalid;
- },
- else => {
- self.command = .{ .set_color = .{
- .kind = .foreground,
- .value = "",
- } };
-
- self.state = .string;
- self.temp_state = .{ .str = &self.command.set_color.value };
- self.buf_start = self.buf_idx - 1;
- },
- },
-
- .query_bg_color => switch (c) {
- '?' => {
- self.command = .{ .report_color = .{ .kind = .background } };
- self.complete = true;
- self.state = .invalid;
- },
- else => {
- self.command = .{ .set_color = .{
- .kind = .background,
- .value = "",
- } };
-
- self.state = .string;
- self.temp_state = .{ .str = &self.command.set_color.value };
- self.buf_start = self.buf_idx - 1;
- },
- },
-
- .query_cursor_color => switch (c) {
- '?' => {
- self.command = .{ .report_color = .{ .kind = .cursor } };
- self.complete = true;
- self.state = .invalid;
- },
- else => {
- self.command = .{ .set_color = .{
- .kind = .cursor,
- .value = "",
- } };
-
- self.state = .string;
- self.temp_state = .{ .str = &self.command.set_color.value };
- self.buf_start = self.buf_idx - 1;
- },
- },
-
.semantic_prompt => switch (c) {
'A' => {
self.state = .semantic_option_start;
@@ -1327,6 +1357,173 @@ pub const Parser = struct {
self.temp_state.str.* = list.items;
}
+ fn parseOSC4(self: *Parser, final: bool) void {
+ assert(self.state == .osc_4_color);
+ assert(self.command == .color_operation);
+ assert(self.command.color_operation.source == .get_set_palette);
+
+ const alloc = self.alloc orelse return;
+ const operations = &self.command.color_operation.operations;
+
+ const str = self.buf[self.buf_start .. self.buf_idx - (1 - @intFromBool(final))];
+ self.buf_start = 0;
+ self.buf_idx = 0;
+
+ var it = std.mem.splitScalar(u8, str, ';');
+ const index_str = it.next() orelse {
+ log.warn("OSC 4 is missing palette index", .{});
+ return;
+ };
+ const spec_str = it.next() orelse {
+ log.warn("OSC 4 is missing color spec", .{});
+ return;
+ };
+ const index = std.fmt.parseUnsigned(u8, index_str, 10) catch |err| switch (err) {
+ error.Overflow, error.InvalidCharacter => {
+ log.warn("invalid color palette index in OSC 4: {s} {}", .{ index_str, err });
+ return;
+ },
+ };
+ if (std.mem.eql(u8, spec_str, "?")) {
+ const op = operations.addOne(alloc) catch |err| {
+ log.warn("unable to append color operation: {}", .{err});
+ return;
+ };
+ op.* = .{
+ .report = .{ .palette = index },
+ };
+ } else {
+ const color = RGB.parse(spec_str) catch |err| {
+ log.warn("invalid color specification in OSC 4: '{s}' {}", .{ spec_str, err });
+ return;
+ };
+ const op = operations.addOne(alloc) catch |err| {
+ log.warn("unable to append color operation: {}", .{err});
+ return;
+ };
+ op.* = .{
+ .set = .{
+ .kind = .{
+ .palette = index,
+ },
+ .color = color,
+ },
+ };
+ }
+ }
+
+ fn parseOSC101112(self: *Parser, final: bool) void {
+ assert(switch (self.state) {
+ .osc_10, .osc_11, .osc_12 => true,
+ else => false,
+ });
+ assert(self.command == .color_operation);
+ assert(self.command.color_operation.source == switch (self.state) {
+ .osc_10 => Command.ColorOperation.Source.get_set_foreground,
+ .osc_11 => Command.ColorOperation.Source.get_set_background,
+ .osc_12 => Command.ColorOperation.Source.get_set_cursor,
+ else => unreachable,
+ });
+
+ const spec_str = self.buf[self.buf_start .. self.buf_idx - (1 - @intFromBool(final))];
+
+ if (self.command.color_operation.operations.count() > 0) {
+ // don't emit the warning if the string is empty
+ if (spec_str.len == 0) return;
+
+ log.warn("OSC 1{s} can only accept 1 color", .{switch (self.state) {
+ .osc_10 => "0",
+ .osc_11 => "1",
+ .osc_12 => "2",
+ else => unreachable,
+ }});
+ return;
+ }
+
+ if (spec_str.len == 0) {
+ log.warn("OSC 1{s} requires an argument", .{switch (self.state) {
+ .osc_10 => "0",
+ .osc_11 => "1",
+ .osc_12 => "2",
+ else => unreachable,
+ }});
+ return;
+ }
+
+ const alloc = self.alloc orelse return;
+ const operations = &self.command.color_operation.operations;
+
+ if (std.mem.eql(u8, spec_str, "?")) {
+ const op = operations.addOne(alloc) catch |err| {
+ log.warn("unable to append color operation: {}", .{err});
+ return;
+ };
+ op.* = .{
+ .report = switch (self.state) {
+ .osc_10 => .foreground,
+ .osc_11 => .background,
+ .osc_12 => .cursor,
+ else => unreachable,
+ },
+ };
+ } else {
+ const color = RGB.parse(spec_str) catch |err| {
+ log.warn("invalid color specification in OSC 1{s}: {s} {}", .{
+ switch (self.state) {
+ .osc_10 => "0",
+ .osc_11 => "1",
+ .osc_12 => "2",
+ else => unreachable,
+ },
+ spec_str,
+ err,
+ });
+ return;
+ };
+ const op = operations.addOne(alloc) catch |err| {
+ log.warn("unable to append color operation: {}", .{err});
+ return;
+ };
+ op.* = .{
+ .set = .{
+ .kind = switch (self.state) {
+ .osc_10 => .foreground,
+ .osc_11 => .background,
+ .osc_12 => .cursor,
+ else => unreachable,
+ },
+ .color = color,
+ },
+ };
+ }
+ }
+
+ fn parseOSC104(self: *Parser, final: bool) void {
+ assert(self.state == .osc_104);
+ assert(self.command == .color_operation);
+ assert(self.command.color_operation.source == .reset_palette);
+
+ const alloc = self.alloc orelse return;
+
+ const index_str = self.buf[self.buf_start .. self.buf_idx - (1 - @intFromBool(final))];
+ self.buf_start = 0;
+ self.buf_idx = 0;
+
+ const index = std.fmt.parseUnsigned(u8, index_str, 10) catch |err| switch (err) {
+ error.Overflow, error.InvalidCharacter => {
+ log.warn("invalid color palette index in OSC 104: {s} {}", .{ index_str, err });
+ return;
+ },
+ };
+ const op = self.command.color_operation.operations.addOne(alloc) catch |err| {
+ log.warn("unable to append color operation: {}", .{err});
+ return;
+ };
+ op.* = .{
+ .reset = .{ .palette = index },
+ };
+ }
+
/// End the sequence and return the command, if any. If the return value
/// is null, then no valid command was found. The optional terminator_ch
/// is the final character in the OSC sequence. This is used to determine
@@ -1350,12 +1547,15 @@ pub const Parser = struct {
.allocable_string => self.endAllocableString(),
.kitty_color_protocol_key => self.endKittyColorProtocolOption(.key_only, true),
.kitty_color_protocol_value => self.endKittyColorProtocolOption(.key_and_value, true),
+ .osc_4_color => self.parseOSC4(true),
+ .osc_10, .osc_11, .osc_12 => self.parseOSC101112(true),
+ .osc_104 => self.parseOSC104(true),
else => {},
}
switch (self.command) {
- .report_color => |*c| c.terminator = Terminator.init(terminator_ch),
- .kitty_color_protocol => |*c| c.terminator = Terminator.init(terminator_ch),
+ .kitty_color_protocol => |*c| c.terminator = .init(terminator_ch),
+ .color_operation => |*c| c.terminator = .init(terminator_ch),
else => {},
}
@@ -1564,17 +1764,109 @@ test "OSC: end_of_input" {
try testing.expect(cmd == .end_of_input);
}
-test "OSC: reset cursor color" {
+test "OSC: OSC110: reset foreground color" {
const testing = std.testing;
- var p: Parser = .{};
+ var p: Parser = .{ .alloc = testing.allocator };
+ defer p.deinit();
+
+ const input = "110";
+ for (input) |ch| p.next(ch);
+
+ const cmd = p.end(null).?;
+ try testing.expect(cmd == .color_operation);
+ try testing.expectEqual(cmd.color_operation.terminator, .st);
+ try testing.expect(cmd.color_operation.source == .reset_foreground);
+ try testing.expect(cmd.color_operation.operations.count() == 1);
+ var it = cmd.color_operation.operations.constIterator(0);
+ {
+ const op = it.next().?;
+ try testing.expect(op.* == .reset);
+ try testing.expectEqual(
+ Command.ColorOperation.Kind.foreground,
+ op.reset,
+ );
+ }
+ try testing.expect(it.next() == null);
+}
+
+test "OSC: OSC111: reset background color" {
+ const testing = std.testing;
+
+ var p: Parser = .{ .alloc = testing.allocator };
+ defer p.deinit();
+
+ const input = "111";
+ for (input) |ch| p.next(ch);
+
+ const cmd = p.end(null).?;
+ try testing.expect(cmd == .color_operation);
+ try testing.expectEqual(cmd.color_operation.terminator, .st);
+ try testing.expect(cmd.color_operation.source == .reset_background);
+ try testing.expect(cmd.color_operation.operations.count() == 1);
+ var it = cmd.color_operation.operations.constIterator(0);
+ {
+ const op = it.next().?;
+ try testing.expect(op.* == .reset);
+ try testing.expectEqual(
+ Command.ColorOperation.Kind.background,
+ op.reset,
+ );
+ }
+ try testing.expect(it.next() == null);
+}
+
+test "OSC: OSC112: reset cursor color" {
+ const testing = std.testing;
+
+ var p: Parser = .{ .alloc = testing.allocator };
+ defer p.deinit();
const input = "112";
for (input) |ch| p.next(ch);
const cmd = p.end(null).?;
- try testing.expect(cmd == .reset_color);
- try testing.expectEqual(cmd.reset_color.kind, .cursor);
+ try testing.expect(cmd == .color_operation);
+ try testing.expectEqual(cmd.color_operation.terminator, .st);
+ try testing.expect(cmd.color_operation.source == .reset_cursor);
+ try testing.expect(cmd.color_operation.operations.count() == 1);
+ var it = cmd.color_operation.operations.constIterator(0);
+ {
+ const op = it.next().?;
+ try testing.expect(op.* == .reset);
+ try testing.expectEqual(
+ Command.ColorOperation.Kind.cursor,
+ op.reset,
+ );
+ }
+ try testing.expect(it.next() == null);
+}
+
+test "OSC: OSC112: reset cursor color with semicolon" {
+ const testing = std.testing;
+
+ var p: Parser = .{ .alloc = testing.allocator };
+ defer p.deinit();
+
+ const input = "112;";
+ for (input) |ch| p.next(ch);
+ log.warn("finish: {s}", .{@tagName(p.state)});
+
+ const cmd = p.end(0x07).?;
+ try testing.expect(cmd == .color_operation);
+ try testing.expectEqual(cmd.color_operation.terminator, .bel);
+ try testing.expect(cmd.color_operation.source == .reset_cursor);
+ try testing.expect(cmd.color_operation.operations.count() == 1);
+ var it = cmd.color_operation.operations.constIterator(0);
+ {
+ const op = it.next().?;
+ try testing.expect(op.* == .reset);
+ try testing.expectEqual(
+ Command.ColorOperation.Kind.cursor,
+ op.reset,
+ );
+ }
+ try testing.expect(it.next() == null);
}
test "OSC: get/set clipboard" {
@@ -1607,9 +1899,8 @@ test "OSC: get/set clipboard (optional parameter)" {
test "OSC: get/set clipboard with allocator" {
const testing = std.testing;
- const alloc = testing.allocator;
- var p: Parser = .{ .alloc = alloc };
+ var p: Parser = .{ .alloc = testing.allocator };
defer p.deinit();
const input = "52;s;?";
@@ -1671,90 +1962,746 @@ test "OSC: longer than buffer" {
try testing.expect(p.complete == false);
}
-test "OSC: report default foreground color" {
+test "OSC: OSC10: report foreground color" {
const testing = std.testing;
- var p: Parser = .{};
+ var p: Parser = .{ .alloc = testing.allocator };
+ defer p.deinit();
const input = "10;?";
for (input) |ch| p.next(ch);
// This corresponds to ST = ESC followed by \
const cmd = p.end('\x1b').?;
- try testing.expect(cmd == .report_color);
- try testing.expectEqual(cmd.report_color.kind, .foreground);
- try testing.expectEqual(cmd.report_color.terminator, .st);
+
+ try testing.expect(cmd == .color_operation);
+ try testing.expectEqual(cmd.color_operation.terminator, .st);
+ try testing.expect(cmd.color_operation.source == .get_set_foreground);
+ try testing.expect(cmd.color_operation.operations.count() == 1);
+ var it = cmd.color_operation.operations.constIterator(0);
+ {
+ const op = it.next().?;
+ try testing.expect(op.* == .report);
+ try testing.expectEqual(
+ Command.ColorOperation.Kind.foreground,
+ op.report,
+ );
+ }
+ try testing.expect(it.next() == null);
}
-test "OSC: set foreground color" {
+test "OSC: OSC10: set foreground color" {
const testing = std.testing;
- var p: Parser = .{};
+ var p: Parser = .{ .alloc = testing.allocator };
+ defer p.deinit();
const input = "10;rgbi:0.0/0.5/1.0";
for (input) |ch| p.next(ch);
const cmd = p.end('\x07').?;
- try testing.expect(cmd == .set_color);
- try testing.expectEqual(cmd.set_color.kind, .foreground);
- try testing.expectEqualStrings(cmd.set_color.value, "rgbi:0.0/0.5/1.0");
+ try testing.expect(cmd == .color_operation);
+ try testing.expectEqual(cmd.color_operation.terminator, .bel);
+ try testing.expect(cmd.color_operation.source == .get_set_foreground);
+ try testing.expect(cmd.color_operation.operations.count() == 1);
+ var it = cmd.color_operation.operations.constIterator(0);
+ {
+ const op = it.next().?;
+ try testing.expect(op.* == .set);
+ try testing.expectEqual(
+ Command.ColorOperation.Kind.foreground,
+ op.set.kind,
+ );
+ try testing.expectEqual(
+ RGB{ .r = 0x00, .g = 0x7f, .b = 0xff },
+ op.set.color,
+ );
+ }
+ try testing.expect(it.next() == null);
}
-test "OSC: report default background color" {
+test "OSC: OSC11: report background color" {
const testing = std.testing;
- var p: Parser = .{};
+ var p: Parser = .{ .alloc = testing.allocator };
+ defer p.deinit();
const input = "11;?";
for (input) |ch| p.next(ch);
// This corresponds to ST = BEL character
const cmd = p.end('\x07').?;
- try testing.expect(cmd == .report_color);
- try testing.expectEqual(cmd.report_color.kind, .background);
- try testing.expectEqual(cmd.report_color.terminator, .bel);
+ try testing.expect(cmd == .color_operation);
+ try testing.expectEqual(cmd.color_operation.terminator, .bel);
+ try testing.expect(cmd.color_operation.source == .get_set_background);
+ try testing.expect(cmd.color_operation.operations.count() == 1);
+ var it = cmd.color_operation.operations.constIterator(0);
+ {
+ const op = it.next().?;
+ try testing.expect(op.* == .report);
+ try testing.expectEqual(
+ Command.ColorOperation.Kind.background,
+ op.report,
+ );
+ }
+ try testing.expectEqual(cmd.color_operation.terminator, .bel);
+ try testing.expect(it.next() == null);
}
-test "OSC: set background color" {
+test "OSC: OSC11: set background color" {
const testing = std.testing;
- var p: Parser = .{};
+ var p: Parser = .{ .alloc = testing.allocator };
+ defer p.deinit();
const input = "11;rgb:f/ff/ffff";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?;
- try testing.expect(cmd == .set_color);
- try testing.expectEqual(cmd.set_color.kind, .background);
- try testing.expectEqualStrings(cmd.set_color.value, "rgb:f/ff/ffff");
+ try testing.expect(cmd == .color_operation);
+ try testing.expectEqual(cmd.color_operation.terminator, .st);
+ try testing.expect(cmd.color_operation.source == .get_set_background);
+ try testing.expect(cmd.color_operation.operations.count() == 1);
+ var it = cmd.color_operation.operations.constIterator(0);
+ {
+ const op = it.next().?;
+ try testing.expect(op.* == .set);
+ try testing.expectEqual(
+ Command.ColorOperation.Kind.background,
+ op.set.kind,
+ );
+ try testing.expectEqual(
+ RGB{ .r = 0xff, .g = 0xff, .b = 0xff },
+ op.set.color,
+ );
+ }
+ try testing.expect(it.next() == null);
}
-test "OSC: get palette color" {
+test "OSC: OSC12: report cursor color" {
const testing = std.testing;
- var p: Parser = .{};
+ var p: Parser = .{ .alloc = testing.allocator };
+ defer p.deinit();
+
+ const input = "12;?";
+ for (input) |ch| p.next(ch);
+
+ // This corresponds to ST = BEL character
+ const cmd = p.end('\x07').?;
+ try testing.expect(cmd == .color_operation);
+ try testing.expectEqual(cmd.color_operation.terminator, .bel);
+ try testing.expect(cmd.color_operation.source == .get_set_cursor);
+ try testing.expect(cmd.color_operation.operations.count() == 1);
+ var it = cmd.color_operation.operations.constIterator(0);
+ {
+ const op = it.next().?;
+ try testing.expect(op.* == .report);
+ try testing.expectEqual(
+ Command.ColorOperation.Kind.cursor,
+ op.report,
+ );
+ }
+ try testing.expectEqual(cmd.color_operation.terminator, .bel);
+ try testing.expect(it.next() == null);
+}
+
+test "OSC: OSC12: set cursor color" {
+ const testing = std.testing;
+
+ var p: Parser = .{ .alloc = testing.allocator };
+ defer p.deinit();
+
+ const input = "12;rgb:f/ff/ffff";
+ for (input) |ch| p.next(ch);
+
+ const cmd = p.end('\x1b').?;
+ try testing.expect(cmd == .color_operation);
+ try testing.expectEqual(cmd.color_operation.terminator, .st);
+ try testing.expect(cmd.color_operation.source == .get_set_cursor);
+ try testing.expect(cmd.color_operation.operations.count() == 1);
+ var it = cmd.color_operation.operations.constIterator(0);
+ {
+ const op = it.next().?;
+ try testing.expect(op.* == .set);
+ try testing.expectEqual(
+ Command.ColorOperation.Kind.cursor,
+ op.set.kind,
+ );
+ try testing.expectEqual(
+ RGB{ .r = 0xff, .g = 0xff, .b = 0xff },
+ op.set.color,
+ );
+ }
+ try testing.expect(it.next() == null);
+}
+
+test "OSC: OSC4: get palette color 1" {
+ const testing = std.testing;
+
+ var p: Parser = .{ .alloc = testing.allocator };
+ defer p.deinit();
const input = "4;1;?";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?;
- try testing.expect(cmd == .report_color);
- try testing.expectEqual(Command.ColorKind{ .palette = 1 }, cmd.report_color.kind);
- try testing.expectEqual(cmd.report_color.terminator, .st);
+ try testing.expect(cmd == .color_operation);
+ try testing.expect(cmd.color_operation.source == .get_set_palette);
+ try testing.expect(cmd.color_operation.operations.count() == 1);
+ var it = cmd.color_operation.operations.constIterator(0);
+ {
+ const op = it.next().?;
+ try testing.expect(op.* == .report);
+ try testing.expectEqual(
+ Command.ColorOperation.Kind{ .palette = 1 },
+ op.report,
+ );
+ try testing.expectEqual(cmd.color_operation.terminator, .st);
+ }
+ try testing.expect(it.next() == null);
}
-test "OSC: set palette color" {
+test "OSC: OSC4: get palette color 2" {
const testing = std.testing;
- var p: Parser = .{};
+ var p: Parser = .{ .alloc = testing.allocator };
+ defer p.deinit();
+
+ const input = "4;1;?;2;?";
+ for (input) |ch| p.next(ch);
+
+ const cmd = p.end('\x1b').?;
+ try testing.expect(cmd == .color_operation);
+ try testing.expect(cmd.color_operation.source == .get_set_palette);
+ try testing.expect(cmd.color_operation.operations.count() == 2);
+ var it = cmd.color_operation.operations.constIterator(0);
+ {
+ const op = it.next().?;
+ try testing.expect(op.* == .report);
+ try testing.expectEqual(
+ Command.ColorOperation.Kind{ .palette = 1 },
+ op.report,
+ );
+ }
+ {
+ const op = it.next().?;
+ try testing.expect(op.* == .report);
+ try testing.expectEqual(
+ Command.ColorOperation.Kind{ .palette = 2 },
+ op.report,
+ );
+ }
+ try testing.expectEqual(cmd.color_operation.terminator, .st);
+ try testing.expect(it.next() == null);
+}
+
+test "OSC: OSC4: set palette color 1" {
+ const testing = std.testing;
+
+ var p: Parser = .{ .alloc = testing.allocator };
+ defer p.deinit();
const input = "4;17;rgb:aa/bb/cc";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?;
- try testing.expect(cmd == .set_color);
- try testing.expectEqual(Command.ColorKind{ .palette = 17 }, cmd.set_color.kind);
- try testing.expectEqualStrings(cmd.set_color.value, "rgb:aa/bb/cc");
+ try testing.expect(cmd == .color_operation);
+ try testing.expect(cmd.color_operation.source == .get_set_palette);
+ try testing.expect(cmd.color_operation.operations.count() == 1);
+ var it = cmd.color_operation.operations.constIterator(0);
+ {
+ const op = it.next().?;
+ try testing.expect(op.* == .set);
+ try testing.expectEqual(
+ Command.ColorOperation.Kind{ .palette = 17 },
+ op.set.kind,
+ );
+ try testing.expectEqual(
+ RGB{ .r = 0xaa, .g = 0xbb, .b = 0xcc },
+ op.set.color,
+ );
+ }
+ try testing.expect(it.next() == null);
+}
+
+test "OSC: OSC4: set palette color 2" {
+ const testing = std.testing;
+
+ var p: Parser = .{ .alloc = testing.allocator };
+ defer p.deinit();
+
+ const input = "4;17;rgb:aa/bb/cc;1;rgb:00/11/22";
+ for (input) |ch| p.next(ch);
+
+ const cmd = p.end('\x1b').?;
+ try testing.expect(cmd == .color_operation);
+ try testing.expect(cmd.color_operation.source == .get_set_palette);
+ try testing.expect(cmd.color_operation.operations.count() == 2);
+ var it = cmd.color_operation.operations.constIterator(0);
+ {
+ const op = it.next().?;
+ try testing.expect(op.* == .set);
+ try testing.expectEqual(
+ Command.ColorOperation.Kind{ .palette = 17 },
+ op.set.kind,
+ );
+ try testing.expectEqual(
+ RGB{ .r = 0xaa, .g = 0xbb, .b = 0xcc },
+ op.set.color,
+ );
+ }
+ {
+ const op = it.next().?;
+ try testing.expect(op.* == .set);
+ try testing.expectEqual(
+ Command.ColorOperation.Kind{ .palette = 1 },
+ op.set.kind,
+ );
+ try testing.expectEqual(
+ RGB{ .r = 0x00, .g = 0x11, .b = 0x22 },
+ op.set.color,
+ );
+ }
+ try testing.expect(it.next() == null);
+}
+
+test "OSC: OSC4: get with invalid index 1" {
+ const testing = std.testing;
+
+ var p: Parser = .{ .alloc = testing.allocator };
+ defer p.deinit();
+
+ const input = "4;1111;?;1;?";
+ for (input) |ch| p.next(ch);
+
+ const cmd = p.end('\x1b').?;
+ try testing.expect(cmd == .color_operation);
+ try testing.expect(cmd.color_operation.source == .get_set_palette);
+ try testing.expect(cmd.color_operation.operations.count() == 1);
+ var it = cmd.color_operation.operations.constIterator(0);
+ {
+ const op = it.next().?;
+ try testing.expect(op.* == .report);
+ try testing.expectEqual(
+ Command.ColorOperation.Kind{ .palette = 1 },
+ op.report,
+ );
+ }
+ try testing.expect(it.next() == null);
+}
+
+test "OSC: OSC4: get with invalid index 2" {
+ const testing = std.testing;
+
+ var p: Parser = .{ .alloc = testing.allocator };
+ defer p.deinit();
+
+ const input = "4;5;?;1111;?;1;?";
+ for (input) |ch| p.next(ch);
+
+ const cmd = p.end('\x1b').?;
+ try testing.expect(cmd == .color_operation);
+ try testing.expect(cmd.color_operation.source == .get_set_palette);
+ try testing.expect(cmd.color_operation.operations.count() == 2);
+ var it = cmd.color_operation.operations.constIterator(0);
+ {
+ const op = it.next().?;
+ try testing.expect(op.* == .report);
+ try testing.expectEqual(
+ Command.ColorOperation.Kind{ .palette = 5 },
+ op.report,
+ );
+ }
+ {
+ const op = it.next().?;
+ try testing.expect(op.* == .report);
+ try testing.expectEqual(
+ Command.ColorOperation.Kind{ .palette = 1 },
+ op.report,
+ );
+ }
+ try testing.expect(it.next() == null);
+}
+
+// Inspired by Microsoft Edit
+test "OSC: OSC4: multiple get 8a" {
+ const testing = std.testing;
+
+ var p: Parser = .{ .alloc = testing.allocator };
+ defer p.deinit();
+
+ const input = "4;0;?;1;?;2;?;3;?;4;?;5;?;6;?;7;?";
+ for (input) |ch| p.next(ch);
+
+ const cmd = p.end('\x1b').?;
+ try testing.expect(cmd == .color_operation);
+ try testing.expect(cmd.color_operation.source == .get_set_palette);
+ try testing.expect(cmd.color_operation.operations.count() == 8);
+ var it = cmd.color_operation.operations.constIterator(0);
+ {
+ const op = it.next().?;
+ try testing.expect(op.* == .report);
+ try testing.expectEqual(
+ Command.ColorOperation.Kind{ .palette = 0 },
+ op.report,
+ );
+ }
+ {
+ const op = it.next().?;
+ try testing.expect(op.* == .report);
+ try testing.expectEqual(
+ Command.ColorOperation.Kind{ .palette = 1 },
+ op.report,
+ );
+ }
+ {
+ const op = it.next().?;
+ try testing.expect(op.* == .report);
+ try testing.expectEqual(
+ Command.ColorOperation.Kind{ .palette = 2 },
+ op.report,
+ );
+ }
+ {
+ const op = it.next().?;
+ try testing.expect(op.* == .report);
+ try testing.expectEqual(
+ Command.ColorOperation.Kind{ .palette = 3 },
+ op.report,
+ );
+ }
+ {
+ const op = it.next().?;
+ try testing.expect(op.* == .report);
+ try testing.expectEqual(
+ Command.ColorOperation.Kind{ .palette = 4 },
+ op.report,
+ );
+ }
+ {
+ const op = it.next().?;
+ try testing.expect(op.* == .report);
+ try testing.expectEqual(
+ Command.ColorOperation.Kind{ .palette = 5 },
+ op.report,
+ );
+ }
+ {
+ const op = it.next().?;
+ try testing.expect(op.* == .report);
+ try testing.expectEqual(
+ Command.ColorOperation.Kind{ .palette = 6 },
+ op.report,
+ );
+ }
+ {
+ const op = it.next().?;
+ try testing.expect(op.* == .report);
+ try testing.expectEqual(
+ Command.ColorOperation.Kind{ .palette = 7 },
+ op.report,
+ );
+ }
+ try testing.expect(it.next() == null);
+}
+
+// Inspired by Microsoft Edit
+test "OSC: OSC4: multiple get 8b" {
+ const testing = std.testing;
+
+ var p: Parser = .{ .alloc = testing.allocator };
+ defer p.deinit();
+
+ const input = "4;8;?;9;?;10;?;11;?;12;?;13;?;14;?;15;?";
+ for (input) |ch| p.next(ch);
+
+ const cmd = p.end('\x1b').?;
+ try testing.expect(cmd == .color_operation);
+ try testing.expect(cmd.color_operation.source == .get_set_palette);
+ try testing.expect(cmd.color_operation.operations.count() == 8);
+ var it = cmd.color_operation.operations.constIterator(0);
+ {
+ const op = it.next().?;
+ try testing.expect(op.* == .report);
+ try testing.expectEqual(
+ Command.ColorOperation.Kind{ .palette = 8 },
+ op.report,
+ );
+ }
+ {
+ const op = it.next().?;
+ try testing.expect(op.* == .report);
+ try testing.expectEqual(
+ Command.ColorOperation.Kind{ .palette = 9 },
+ op.report,
+ );
+ }
+ {
+ const op = it.next().?;
+ try testing.expect(op.* == .report);
+ try testing.expectEqual(
+ Command.ColorOperation.Kind{ .palette = 10 },
+ op.report,
+ );
+ }
+ {
+ const op = it.next().?;
+ try testing.expect(op.* == .report);
+ try testing.expectEqual(
+ Command.ColorOperation.Kind{ .palette = 11 },
+ op.report,
+ );
+ }
+ {
+ const op = it.next().?;
+ try testing.expect(op.* == .report);
+ try testing.expectEqual(
+ Command.ColorOperation.Kind{ .palette = 12 },
+ op.report,
+ );
+ }
+ {
+ const op = it.next().?;
+ try testing.expect(op.* == .report);
+ try testing.expectEqual(
+ Command.ColorOperation.Kind{ .palette = 13 },
+ op.report,
+ );
+ }
+ {
+ const op = it.next().?;
+ try testing.expect(op.* == .report);
+ try testing.expectEqual(
+ Command.ColorOperation.Kind{ .palette = 14 },
+ op.report,
+ );
+ }
+ {
+ const op = it.next().?;
+ try testing.expect(op.* == .report);
+ try testing.expectEqual(
+ Command.ColorOperation.Kind{ .palette = 15 },
+ op.report,
+ );
+ }
+ try testing.expect(it.next() == null);
+}
+
+test "OSC: OSC4: set with invalid index" {
+ const testing = std.testing;
+
+ var p: Parser = .{ .alloc = testing.allocator };
+ defer p.deinit();
+
+ const input = "4;256;#ffffff;1;#aabbcc";
+ for (input) |ch| p.next(ch);
+
+ const cmd = p.end('\x1b').?;
+ try testing.expect(cmd == .color_operation);
+ try testing.expect(cmd.color_operation.source == .get_set_palette);
+ try testing.expect(cmd.color_operation.operations.count() == 1);
+ var it = cmd.color_operation.operations.constIterator(0);
+ {
+ const op = it.next().?;
+ try testing.expect(op.* == .set);
+ try testing.expectEqual(
+ Command.ColorOperation.Kind{ .palette = 1 },
+ op.set.kind,
+ );
+ try testing.expectEqual(
+ RGB{ .r = 0xaa, .g = 0xbb, .b = 0xcc },
+ op.set.color,
+ );
+ }
+ try testing.expect(it.next() == null);
+}
+
+test "OSC: OSC4: mix get/set palette color" {
+ const testing = std.testing;
+
+ var p: Parser = .{ .alloc = testing.allocator };
+ defer p.deinit();
+
+ const input = "4;17;rgb:aa/bb/cc;254;?";
+ for (input) |ch| p.next(ch);
+
+ const cmd = p.end('\x1b').?;
+ try testing.expect(cmd == .color_operation);
+ try testing.expect(cmd.color_operation.source == .get_set_palette);
+ try testing.expect(cmd.color_operation.operations.count() == 2);
+ var it = cmd.color_operation.operations.constIterator(0);
+ {
+ const op = it.next().?;
+ try testing.expect(op.* == .set);
+ try testing.expectEqual(
+ Command.ColorOperation.Kind{ .palette = 17 },
+ op.set.kind,
+ );
+ try testing.expectEqual(
+ RGB{ .r = 0xaa, .g = 0xbb, .b = 0xcc },
+ op.set.color,
+ );
+ }
+ {
+ const op = it.next().?;
+ try testing.expect(op.* == .report);
+ try testing.expectEqual(
+ Command.ColorOperation.Kind{ .palette = 254 },
+ op.report,
+ );
+ }
+ try testing.expect(it.next() == null);
+}
+
+test "OSC: OSC4: incomplete color/spec 1" {
+ const testing = std.testing;
+
+ var p: Parser = .{ .alloc = testing.allocator };
+ defer p.deinit();
+
+ const input = "4;17";
+ for (input) |ch| p.next(ch);
+
+ const cmd = p.end('\x1b').?;
+ try testing.expect(cmd == .color_operation);
+ try testing.expect(cmd.color_operation.source == .get_set_palette);
+ try testing.expect(cmd.color_operation.operations.count() == 0);
+ var it = cmd.color_operation.operations.constIterator(0);
+ try testing.expect(it.next() == null);
+}
+
+test "OSC: OSC4: incomplete color/spec 2" {
+ const testing = std.testing;
+
+ var p: Parser = .{ .alloc = testing.allocator };
+ defer p.deinit();
+
+ const input = "4;17;?;42";
+ for (input) |ch| p.next(ch);
+
+ const cmd = p.end('\x1b').?;
+ try testing.expect(cmd == .color_operation);
+ try testing.expect(cmd.color_operation.source == .get_set_palette);
+ try testing.expect(cmd.color_operation.operations.count() == 1);
+ var it = cmd.color_operation.operations.constIterator(0);
+ {
+ const op = it.next().?;
+ try testing.expect(op.* == .report);
+ try testing.expectEqual(
+ Command.ColorOperation.Kind{ .palette = 17 },
+ op.report,
+ );
+ }
+ try testing.expect(it.next() == null);
+}
+
+test "OSC: OSC104: reset palette color 1" {
+ const testing = std.testing;
+
+ var p: Parser = .{ .alloc = testing.allocator };
+ defer p.deinit();
+
+ const input = "104;17";
+ for (input) |ch| p.next(ch);
+
+ const cmd = p.end('\x1b').?;
+ try testing.expect(cmd == .color_operation);
+ try testing.expect(cmd.color_operation.source == .reset_palette);
+ try testing.expect(cmd.color_operation.operations.count() == 1);
+ var it = cmd.color_operation.operations.constIterator(0);
+ {
+ const op = it.next().?;
+ try testing.expect(op.* == .reset);
+ try testing.expectEqual(
+ Command.ColorOperation.Kind{ .palette = 17 },
+ op.reset,
+ );
+ }
+ try testing.expect(it.next() == null);
+}
+
+test "OSC: OSC104: reset palette color 2" {
+ const testing = std.testing;
+
+ var p: Parser = .{ .alloc = testing.allocator };
+ defer p.deinit();
+
+ const input = "104;17;111";
+ for (input) |ch| p.next(ch);
+
+ const cmd = p.end('\x1b').?;
+ try testing.expect(cmd == .color_operation);
+ try testing.expect(cmd.color_operation.source == .reset_palette);
+ try testing.expectEqual(2, cmd.color_operation.operations.count());
+ var it = cmd.color_operation.operations.constIterator(0);
+ {
+ const op = it.next().?;
+ try testing.expect(op.* == .reset);
+ try testing.expectEqual(
+ Command.ColorOperation.Kind{ .palette = 17 },
+ op.reset,
+ );
+ }
+ {
+ const op = it.next().?;
+ try testing.expect(op.* == .reset);
+ try testing.expectEqual(
+ Command.ColorOperation.Kind{ .palette = 111 },
+ op.reset,
+ );
+ }
+ try testing.expect(it.next() == null);
+}
+
+test "OSC: OSC104: invalid palette index" {
+ const testing = std.testing;
+
+ var p: Parser = .{ .alloc = testing.allocator };
+ defer p.deinit();
+
+ const input = "104;ffff;111";
+ for (input) |ch| p.next(ch);
+
+ const cmd = p.end('\x1b').?;
+ try testing.expect(cmd == .color_operation);
+ try testing.expect(cmd.color_operation.source == .reset_palette);
+ try testing.expect(cmd.color_operation.operations.count() == 1);
+ var it = cmd.color_operation.operations.constIterator(0);
+ {
+ const op = it.next().?;
+ try testing.expect(op.* == .reset);
+ try testing.expectEqual(
+ Command.ColorOperation.Kind{ .palette = 111 },
+ op.reset,
+ );
+ }
+ try testing.expect(it.next() == null);
+}
+
+test "OSC: OSC104: empty palette index" {
+ const testing = std.testing;
+
+ var p: Parser = .{ .alloc = testing.allocator };
+ defer p.deinit();
+
+ const input = "104;;111";
+ for (input) |ch| p.next(ch);
+
+ const cmd = p.end('\x1b').?;
+ try testing.expect(cmd == .color_operation);
+ try testing.expect(cmd.color_operation.source == .reset_palette);
+ try testing.expect(cmd.color_operation.operations.count() == 1);
+ var it = cmd.color_operation.operations.constIterator(0);
+ {
+ const op = it.next().?;
+ try testing.expect(op.* == .reset);
+ try testing.expectEqual(
+ Command.ColorOperation.Kind{ .palette = 111 },
+ op.reset,
+ );
+ }
+ try std.testing.expect(it.next() == null);
}
test "OSC: conemu sleep" {
diff --git a/src/terminal/page.zig b/src/terminal/page.zig
index acb757592..fea16c28b 100644
--- a/src/terminal/page.zig
+++ b/src/terminal/page.zig
@@ -241,23 +241,23 @@ pub const Page = struct {
l.styles_layout,
.{},
),
- .string_alloc = StringAlloc.init(
+ .string_alloc = .init(
buf.add(l.string_alloc_start),
l.string_alloc_layout,
),
- .grapheme_alloc = GraphemeAlloc.init(
+ .grapheme_alloc = .init(
buf.add(l.grapheme_alloc_start),
l.grapheme_alloc_layout,
),
- .grapheme_map = GraphemeMap.init(
+ .grapheme_map = .init(
buf.add(l.grapheme_map_start),
l.grapheme_map_layout,
),
- .hyperlink_map = hyperlink.Map.init(
+ .hyperlink_map = .init(
buf.add(l.hyperlink_map_start),
l.hyperlink_map_layout,
),
- .hyperlink_set = hyperlink.Set.init(
+ .hyperlink_set = .init(
buf.add(l.hyperlink_set_start),
l.hyperlink_set_layout,
.{},
@@ -280,7 +280,7 @@ pub const Page = struct {
// We zero the page memory as u64 instead of u8 because
// we can and it's empirically quite a bit faster.
@memset(@as([*]u64, @ptrCast(self.memory))[0 .. self.memory.len / 8], 0);
- self.* = initBuf(OffsetBuf.init(self.memory), layout(self.capacity));
+ self.* = initBuf(.init(self.memory), layout(self.capacity));
}
pub const IntegrityError = error{
@@ -1316,7 +1316,12 @@ pub const Page = struct {
/// Set the graphemes for the given cell. This asserts that the cell
/// has no graphemes set, and only contains a single codepoint.
- pub fn setGraphemes(self: *Page, row: *Row, cell: *Cell, cps: []u21) GraphemeError!void {
+ pub fn setGraphemes(
+ self: *Page,
+ row: *Row,
+ cell: *Cell,
+ cps: []const u21,
+ ) GraphemeError!void {
defer self.assertIntegrity();
assert(cell.codepoint() > 0);
@@ -2260,7 +2265,7 @@ test "Page appendGrapheme small" {
defer page.deinit();
const rac = page.getRowAndCell(0, 0);
- rac.cell.* = Cell.init(0x09);
+ rac.cell.* = .init(0x09);
// One
try page.appendGrapheme(rac.row, rac.cell, 0x0A);
@@ -2289,7 +2294,7 @@ test "Page appendGrapheme larger than chunk" {
defer page.deinit();
const rac = page.getRowAndCell(0, 0);
- rac.cell.* = Cell.init(0x09);
+ rac.cell.* = .init(0x09);
const count = grapheme_chunk_len * 10;
for (0..count) |i| {
@@ -2312,11 +2317,11 @@ test "Page clearGrapheme not all cells" {
defer page.deinit();
const rac = page.getRowAndCell(0, 0);
- rac.cell.* = Cell.init(0x09);
+ rac.cell.* = .init(0x09);
try page.appendGrapheme(rac.row, rac.cell, 0x0A);
const rac2 = page.getRowAndCell(1, 0);
- rac2.cell.* = Cell.init(0x09);
+ rac2.cell.* = .init(0x09);
try page.appendGrapheme(rac2.row, rac2.cell, 0x0A);
// Clear it
diff --git a/src/terminal/point.zig b/src/terminal/point.zig
index 12b71014b..f2544f90c 100644
--- a/src/terminal/point.zig
+++ b/src/terminal/point.zig
@@ -3,10 +3,12 @@ const Allocator = std.mem.Allocator;
const assert = std.debug.assert;
const size = @import("size.zig");
-/// The possible reference locations for a point. When someone says "(42, 80)" in the context of a terminal, that could mean multiple
-/// things: it is in the current visible viewport? the current active
-/// area of the screen where the cursor is? the entire scrollback history?
-/// etc. This tag is used to differentiate those cases.
+/// The possible reference locations for a point. When someone says "(42, 80)"
+/// in the context of a terminal, that could mean multiple things: it is in the
+/// current visible viewport? the current active area of the screen where the
+/// cursor is? the entire scrollback history? etc.
+///
+/// This tag is used to differentiate those cases.
pub const Tag = enum {
/// Top-left is part of the active area where a running program can
/// jump the cursor and make changes. The active area is the "editable"
diff --git a/src/terminal/ref_counted_set.zig b/src/terminal/ref_counted_set.zig
index 8023461f3..153e331a6 100644
--- a/src/terminal/ref_counted_set.zig
+++ b/src/terminal/ref_counted_set.zig
@@ -115,7 +115,7 @@ pub fn RefCountedSet(
/// input. We handle this gracefully by returning an error
/// anywhere where we're about to insert if there's any
/// item with a PSL in the last slot of the stats array.
- psl_stats: [32]Id = [_]Id{0} ** 32,
+ psl_stats: [32]Id = @splat(0),
/// The backing store of items
items: Offset(Item),
@@ -663,7 +663,7 @@ pub fn RefCountedSet(
const table = self.table.ptr(base);
const items = self.items.ptr(base);
- var psl_stats: [32]Id = [_]Id{0} ** 32;
+ var psl_stats: [32]Id = @splat(0);
for (items[0..self.layout.cap], 0..) |item, id| {
if (item.meta.bucket < std.math.maxInt(Id)) {
@@ -676,7 +676,7 @@ pub fn RefCountedSet(
assert(std.mem.eql(Id, &psl_stats, &self.psl_stats));
- psl_stats = [_]Id{0} ** 32;
+ psl_stats = @splat(0);
for (table[0..self.layout.table_cap], 0..) |id, bucket| {
const item = items[id];
diff --git a/src/terminal/search.zig b/src/terminal/search.zig
index 56b181c48..2f87f894b 100644
--- a/src/terminal/search.zig
+++ b/src/terminal/search.zig
@@ -365,7 +365,7 @@ const SlidingWindow = struct {
}
self.assertIntegrity();
- return Selection.init(tl, br, false);
+ return .init(tl, br, false);
}
/// Convert a data index into a pin.
@@ -417,7 +417,7 @@ const SlidingWindow = struct {
// Initialize our metadata for the node.
var meta: Meta = .{
.node = node,
- .cell_map = Page.CellMap.init(alloc),
+ .cell_map = .init(alloc),
};
errdefer meta.deinit();
diff --git a/src/terminal/sgr.zig b/src/terminal/sgr.zig
index 2bc32c5f9..e4b85fbdd 100644
--- a/src/terminal/sgr.zig
+++ b/src/terminal/sgr.zig
@@ -98,7 +98,7 @@ pub const Attribute = union(enum) {
/// Parser parses the attributes from a list of SGR parameters.
pub const Parser = struct {
params: []const u16,
- params_sep: SepList = SepList.initEmpty(),
+ params_sep: SepList = .initEmpty(),
idx: usize = 0,
/// Next returns the next attribute or null if there are no more attributes.
@@ -376,7 +376,7 @@ fn testParse(params: []const u16) Attribute {
}
fn testParseColon(params: []const u16) Attribute {
- var p: Parser = .{ .params = params, .params_sep = SepList.initFull() };
+ var p: Parser = .{ .params = params, .params_sep = .initFull() };
return p.next().?;
}
diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig
index 76fa6c129..fd30720b3 100644
--- a/src/terminal/stream.zig
+++ b/src/terminal/stream.zig
@@ -1555,23 +1555,9 @@ pub fn Stream(comptime Handler: type) type {
} else log.warn("unimplemented OSC callback: {}", .{cmd});
},
- .report_color => |v| {
- if (@hasDecl(T, "reportColor")) {
- try self.handler.reportColor(v.kind, v.terminator);
- return;
- } else log.warn("unimplemented OSC callback: {}", .{cmd});
- },
-
- .set_color => |v| {
- if (@hasDecl(T, "setColor")) {
- try self.handler.setColor(v.kind, v.value);
- return;
- } else log.warn("unimplemented OSC callback: {}", .{cmd});
- },
-
- .reset_color => |v| {
- if (@hasDecl(T, "resetColor")) {
- try self.handler.resetColor(v.kind, v.value);
+ .color_operation => |v| {
+ if (@hasDecl(T, "handleColorOperation")) {
+ try self.handler.handleColorOperation(v.source, &v.operations, v.terminator);
return;
} else log.warn("unimplemented OSC callback: {}", .{cmd});
},
diff --git a/src/terminal/style.zig b/src/terminal/style.zig
index 7f176561b..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;
@@ -87,10 +84,9 @@ pub const Style = struct {
/// True if the style is equal to another style.
pub fn eql(self: Style, other: Style) bool {
- const packed_self = PackedStyle.fromStyle(self);
- const packed_other = PackedStyle.fromStyle(other);
- // TODO: in Zig 0.14, equating packed structs is allowed. Remove this work around.
- return @as(u128, @bitCast(packed_self)) == @as(u128, @bitCast(packed_other));
+ // We convert the styles to packed structs and compare as integers
+ // because this is much faster than comparing each field separately.
+ return PackedStyle.fromStyle(self) == PackedStyle.fromStyle(other);
}
/// Returns the bg color for a cell with this style given the cell
@@ -303,9 +299,9 @@ pub const Style = struct {
.underline = std.meta.activeTag(style.underline_color),
},
.data = .{
- .fg = Data.fromColor(style.fg_color),
- .bg = Data.fromColor(style.bg_color),
- .underline = Data.fromColor(style.underline_color),
+ .fg = .fromColor(style.fg_color),
+ .bg = .fromColor(style.bg_color),
+ .underline = .fromColor(style.underline_color),
},
.flags = style.flags,
};
@@ -314,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));
+ }
}
};
@@ -350,7 +349,7 @@ test "Set basic usage" {
const style: Style = .{ .flags = .{ .bold = true } };
const style2: Style = .{ .flags = .{ .italic = true } };
- var set = Set.init(OffsetBuf.init(buf), layout, .{});
+ var set = Set.init(.init(buf), layout, .{});
// Add style
const id = try set.add(buf, style);
diff --git a/src/terminal/x11_color.zig b/src/terminal/x11_color.zig
index 88bc30f09..977cd4538 100644
--- a/src/terminal/x11_color.zig
+++ b/src/terminal/x11_color.zig
@@ -33,7 +33,7 @@ fn colorMap() !ColorMap {
}
assert(i == len);
- return ColorMap.initComptime(kvs);
+ return .initComptime(kvs);
}
/// This is the rgb.txt file from the X11 project. This was last sourced
diff --git a/src/terminfo/Source.zig b/src/terminfo/Source.zig
index 8ffd9cabb..7692e6f54 100644
--- a/src/terminfo/Source.zig
+++ b/src/terminfo/Source.zig
@@ -74,7 +74,7 @@ pub fn xtgettcapMap(comptime self: Source) std.StaticStringMap([]const u8) {
// We have all of our capabilities plus To, TN, and RGB which aren't
// in the capabilities list but are query-able.
const len = self.capabilities.len + 3;
- var kvs: [len]KV = .{.{ "", "" }} ** len;
+ var kvs: [len]KV = @splat(.{ "", "" });
// We first build all of our entries with raw K=V pairs.
kvs[0] = .{ "TN", self.names[0] };
diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig
index 23c626879..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,61 +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;
- }
-
- // 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) {
- // We output a message so that the user knows whats going on and
- // doesn't think their terminal just froze.
- 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);
- }
-
- 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 = {} });
}
@@ -561,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.
@@ -656,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,
@@ -1362,6 +1219,13 @@ pub const ReadThread = struct {
// Always close our end of the pipe when we exit.
defer posix.close(quit);
+ // Right now, on Darwin, `std.Thread.setName` can only name the current
+ // thread, and we have no way to get the current thread from within it,
+ // so instead we use this code to name the thread instead.
+ if (builtin.os.tag.isDarwin()) {
+ internal_os.macos.pthread_setname_np(&"io-reader".*);
+ }
+
// Setup our crash metadata
crash.sentry.thread_state = .{
.type = .io,
diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig
index ecfb9951e..865a2df86 100644
--- a/src/termio/Termio.zig
+++ b/src/termio/Termio.zig
@@ -70,6 +70,89 @@ terminal_stream: terminalpkg.Stream(StreamHandler),
/// flooding with cursor resets.
last_cursor_reset: ?std.time.Instant = null,
+/// State we have for thread enter. This may be null if we don't need
+/// to keep track of any state or if its already been freed.
+thread_enter_state: ?*ThreadEnterState = null,
+
+/// The state we need to keep around only until we enter the IO
+/// thread. Then we can throw it all away.
+const ThreadEnterState = struct {
+ arena: ArenaAllocator,
+
+ /// Initial input to send to the subprocess after starting. This
+ /// memory is freed once the subprocess start is attempted, even
+ /// if it fails, because Exec only starts once.
+ input: configpkg.io.RepeatableReadableIO,
+
+ pub fn create(
+ alloc: Allocator,
+ config: *const configpkg.Config,
+ ) !?*ThreadEnterState {
+ // If we have no input then we have no thread enter state
+ if (config.input.list.items.len == 0) return null;
+
+ // Create our arena allocator
+ var arena = ArenaAllocator.init(alloc);
+ errdefer arena.deinit();
+ const arena_alloc = arena.allocator();
+
+ // Allocate our ThreadEnterState
+ const ptr = try arena_alloc.create(ThreadEnterState);
+
+ // Copy the input from the config
+ const input = try config.input.cloneParsed(arena_alloc);
+
+ // Return the initialized state
+ ptr.* = .{
+ .arena = arena,
+ .input = input,
+ };
+ return ptr;
+ }
+
+ pub fn destroy(self: *ThreadEnterState) void {
+ self.arena.deinit();
+ }
+
+ /// Prepare the inputs for use. Allocations happen on the arena.
+ pub fn prepareInput(
+ self: *ThreadEnterState,
+ ) (Allocator.Error || error{InputNotFound})![]const Input {
+ const alloc = self.arena.allocator();
+
+ var input = try alloc.alloc(
+ Input,
+ self.input.list.items.len,
+ );
+ for (self.input.list.items, 0..) |item, i| {
+ input[i] = switch (item) {
+ .raw => |v| .{ .string = try alloc.dupe(u8, v) },
+ .path => |path| file: {
+ const f = std.fs.cwd().openFile(
+ path,
+ .{},
+ ) catch |err| {
+ log.warn("failed to open input file={s} err={}", .{
+ path,
+ err,
+ });
+ return error.InputNotFound;
+ };
+
+ break :file .{ .file = f };
+ },
+ };
+ }
+
+ return input;
+ }
+
+ const Input = union(enum) {
+ string: []const u8,
+ file: std.fs.File,
+ };
+};
+
/// The configuration for this IO that is derived from the main
/// configuration. This must be exported so that we don't need to
/// pass around Config pointers which makes memory management a pain.
@@ -85,8 +168,6 @@ 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,
enquiry_response: []const u8,
pub fn init(
@@ -107,8 +188,6 @@ 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",
.enquiry_response = try alloc.dupe(u8, config.@"enquiry-response"),
// This has to be last so that we copy AFTER the arena allocations
@@ -211,6 +290,11 @@ pub fn init(self: *Termio, alloc: Allocator, opts: termio.Options) !void {
};
};
+ const thread_enter_state = try ThreadEnterState.create(
+ alloc,
+ opts.full_config,
+ );
+
self.* = .{
.alloc = alloc,
.terminal = term,
@@ -232,6 +316,7 @@ pub fn init(self: *Termio, alloc: Allocator, opts: termio.Options) !void {
},
},
},
+ .thread_enter_state = thread_enter_state,
};
}
@@ -244,9 +329,30 @@ pub fn deinit(self: *Termio) void {
// Clear any StreamHandler state
self.terminal_stream.handler.deinit();
self.terminal_stream.deinit();
+
+ // Clear any initial state if we have it
+ if (self.thread_enter_state) |v| v.destroy();
}
-pub fn threadEnter(self: *Termio, thread: *termio.Thread, data: *ThreadData) !void {
+pub fn threadEnter(
+ self: *Termio,
+ thread: *termio.Thread,
+ data: *ThreadData,
+) !void {
+ // Always free our thread enter state when we're done.
+ defer if (self.thread_enter_state) |v| {
+ v.destroy();
+ self.thread_enter_state = null;
+ };
+
+ // If we have thread enter state then we're going to validate
+ // and set that all up now so that we can error before we actually
+ // start the command and pty.
+ const inputs: ?[]const ThreadEnterState.Input = if (self.thread_enter_state) |v|
+ try v.prepareInput()
+ else
+ null;
+
data.* = .{
.alloc = self.alloc,
.loop = &thread.loop,
@@ -258,6 +364,29 @@ pub fn threadEnter(self: *Termio, thread: *termio.Thread, data: *ThreadData) !vo
// Setup our backend
try self.backend.threadEnter(self.alloc, self, data);
+ errdefer self.backend.threadExit(data);
+
+ // If we have inputs, then queue them all up.
+ for (inputs orelse &.{}) |input| switch (input) {
+ .string => |v| self.queueWrite(data, v, false) catch |err| {
+ log.warn("failed to queue input string err={}", .{err});
+ return error.InputFailed;
+ },
+ .file => |f| self.queueWrite(
+ data,
+ f.readToEndAlloc(
+ self.alloc,
+ 10 * 1024 * 1024, // 10 MiB max
+ ) catch |err| {
+ log.warn("failed to read input file err={}", .{err});
+ return error.InputFailed;
+ },
+ false,
+ ) catch |err| {
+ log.warn("failed to queue input file err={}", .{err});
+ return error.InputFailed;
+ },
+ };
}
pub fn threadExit(self: *Termio, data: *ThreadData) void {
@@ -527,15 +656,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 d8018341d..a701a29f8 100644
--- a/src/termio/Thread.zig
+++ b/src/termio/Thread.zig
@@ -16,6 +16,7 @@ const ArenaAllocator = std.heap.ArenaAllocator;
const builtin = @import("builtin");
const xev = @import("../global.zig").xev;
const crash = @import("../crash/main.zig");
+const internal_os = @import("../os/main.zig");
const termio = @import("../termio.zig");
const renderer = @import("../renderer.zig");
const BlockingQueue = @import("../datastruct/main.zig").BlockingQueue;
@@ -145,6 +146,8 @@ pub fn threadMain(self: *Thread, io: *termio.Termio) void {
// have "OpenptyFailed".
const Err = @TypeOf(err) || error{
OpenptyFailed,
+ InputNotFound,
+ InputFailed,
};
switch (@as(Err, @errorCast(err))) {
@@ -164,6 +167,24 @@ pub fn threadMain(self: *Thread, io: *termio.Termio) void {
t.printString(str) catch {};
},
+ error.InputNotFound,
+ error.InputFailed,
+ => {
+ const str =
+ \\A configured `input` path was not found, was not readable,
+ \\was too large, or the underlying pty failed to accept
+ \\the write.
+ \\
+ \\Ghostty can't continue since it can't guarantee that
+ \\initial terminal state will be as desired. Please review
+ \\the value of `input` in your configuration file and
+ \\ensure that all the path values exist and are readable.
+ ;
+
+ t.eraseDisplay(.complete, false);
+ t.printString(str) catch {};
+ },
+
else => {
const str = std.fmt.allocPrint(
alloc,
@@ -202,6 +223,13 @@ pub fn threadMain(self: *Thread, io: *termio.Termio) void {
fn threadMain_(self: *Thread, io: *termio.Termio) !void {
defer log.debug("IO thread exited", .{});
+ // Right now, on Darwin, `std.Thread.setName` can only name the current
+ // thread, and we have no way to get the current thread from within it,
+ // so instead we use this code to name the thread instead.
+ if (builtin.os.tag.isDarwin()) {
+ internal_os.macos.pthread_setname_np(&"io".*);
+ }
+
// Setup our crash metadata
crash.sentry.thread_state = .{
.type = .io,
@@ -283,7 +311,6 @@ fn drainMailbox(
.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,
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..e497a298f 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");
@@ -58,15 +59,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 299c7cd45..90add84ae 100644
--- a/src/termio/stream_handler.zig
+++ b/src/termio/stream_handler.zig
@@ -582,36 +582,33 @@ pub const StreamHandler = struct {
self.terminal.scrolling_region.right = self.terminal.cols - 1;
},
- .alt_screen => {
- const opts: terminal.Terminal.AlternateScreenOptions = .{
- .cursor_save = false,
- .clear_on_enter = false,
- };
-
- if (enabled)
- self.terminal.alternateScreen(opts)
- else
- self.terminal.primaryScreen(opts);
+ .alt_screen_legacy => {
+ self.terminal.switchScreenMode(.@"47", enabled);
+ try self.queueRender();
+ },
- // Schedule a render since we changed screens
+ .alt_screen => {
+ self.terminal.switchScreenMode(.@"1047", enabled);
try self.queueRender();
},
.alt_screen_save_cursor_clear_enter => {
- const opts: terminal.Terminal.AlternateScreenOptions = .{
- .cursor_save = true,
- .clear_on_enter = true,
- };
-
- if (enabled)
- self.terminal.alternateScreen(opts)
- else
- self.terminal.primaryScreen(opts);
-
- // Schedule a render since we changed screens
+ self.terminal.switchScreenMode(.@"1049", enabled);
try self.queueRender();
},
+ // Mode 1048 is xterm's conditional save cursor depending
+ // on if alt screen is enabled or not (at the terminal emulator
+ // level). Alt screen is always enabled for us so this just
+ // does a save/restore cursor.
+ .save_cursor => {
+ if (enabled) {
+ self.terminal.saveCursor();
+ } else {
+ try self.terminal.restoreCursor();
+ }
+ },
+
// Force resize back to the window size
.enable_mode_3 => {
const grid_size = self.size.grid();
@@ -1084,7 +1081,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;
};
@@ -1185,200 +1182,185 @@ pub const StreamHandler = struct {
}
}
- /// Implements OSC 4, OSC 10, and OSC 11, which reports palette color,
- /// default foreground color, and background color respectively.
- pub fn reportColor(
+ pub fn handleColorOperation(
self: *StreamHandler,
- kind: terminal.osc.Command.ColorKind,
+ source: terminal.osc.Command.ColorOperation.Source,
+ operations: *const terminal.osc.Command.ColorOperation.List,
terminator: terminal.osc.Terminator,
) !void {
- if (self.osc_color_report_format == .none) return;
-
- const color = switch (kind) {
- .palette => |i| self.terminal.color_palette.colors[i],
- .foreground => self.foreground_color orelse self.default_foreground_color,
- .background => self.background_color orelse self.default_background_color,
- .cursor => self.cursor_color orelse
- self.default_cursor_color orelse
- self.foreground_color orelse
- self.default_foreground_color,
- };
-
- var msg: termio.Message = .{ .write_small = .{} };
- const resp = switch (self.osc_color_report_format) {
- .@"16-bit" => switch (kind) {
- .palette => |i| try std.fmt.bufPrint(
- &msg.write_small.data,
- "\x1B]{s};{d};rgb:{x:0>4}/{x:0>4}/{x:0>4}{s}",
- .{
- kind.code(),
- i,
- @as(u16, color.r) * 257,
- @as(u16, color.g) * 257,
- @as(u16, color.b) * 257,
- terminator.string(),
- },
- ),
- else => try std.fmt.bufPrint(
- &msg.write_small.data,
- "\x1B]{s};rgb:{x:0>4}/{x:0>4}/{x:0>4}{s}",
- .{
- kind.code(),
- @as(u16, color.r) * 257,
- @as(u16, color.g) * 257,
- @as(u16, color.b) * 257,
- terminator.string(),
- },
- ),
- },
+ // return early if there is nothing to do
+ if (operations.count() == 0) return;
- .@"8-bit" => switch (kind) {
- .palette => |i| try std.fmt.bufPrint(
- &msg.write_small.data,
- "\x1B]{s};{d};rgb:{x:0>2}/{x:0>2}/{x:0>2}{s}",
- .{
- kind.code(),
- i,
- @as(u16, color.r),
- @as(u16, color.g),
- @as(u16, color.b),
- terminator.string(),
- },
- ),
- else => try std.fmt.bufPrint(
- &msg.write_small.data,
- "\x1B]{s};rgb:{x:0>2}/{x:0>2}/{x:0>2}{s}",
- .{
- kind.code(),
- @as(u16, color.r),
- @as(u16, color.g),
- @as(u16, color.b),
- terminator.string(),
- },
- ),
- },
- .none => unreachable, // early return above
- };
- msg.write_small.len = @intCast(resp.len);
- self.messageWriter(msg);
- }
+ var buffer: [1024]u8 = undefined;
+ var fba: std.heap.FixedBufferAllocator = .init(&buffer);
+ const alloc = fba.allocator();
- pub fn setColor(
- self: *StreamHandler,
- kind: terminal.osc.Command.ColorKind,
- value: []const u8,
- ) !void {
- const color = try terminal.color.RGB.parse(value);
+ var response: std.ArrayListUnmanaged(u8) = .empty;
+ const writer = response.writer(alloc);
- switch (kind) {
- .palette => |i| {
- self.terminal.flags.dirty.palette = true;
- self.terminal.color_palette.colors[i] = color;
- self.terminal.color_palette.mask.set(i);
- },
- .foreground => {
- self.foreground_color = color;
- _ = self.renderer_mailbox.push(.{
- .foreground_color = color,
- }, .{ .forever = {} });
- },
- .background => {
- self.background_color = color;
- _ = self.renderer_mailbox.push(.{
- .background_color = color,
- }, .{ .forever = {} });
- },
- .cursor => {
- self.cursor_color = color;
- _ = self.renderer_mailbox.push(.{
- .cursor_color = color,
- }, .{ .forever = {} });
- },
- }
+ var report: bool = false;
- // Notify the surface of the color change
- self.surfaceMessageWriter(.{ .color_change = .{
- .kind = kind,
- .color = color,
- } });
- }
+ try writer.print("\x1b]{}", .{source});
- pub fn resetColor(
- self: *StreamHandler,
- kind: terminal.osc.Command.ColorKind,
- value: []const u8,
- ) !void {
- switch (kind) {
- .palette => {
- const mask = &self.terminal.color_palette.mask;
- if (value.len == 0) {
- // Find all bit positions in the mask which are set and
- // reset those indices to the default palette
- var it = mask.iterator(.{});
- while (it.next()) |i| {
- self.terminal.flags.dirty.palette = true;
- self.terminal.color_palette.colors[i] = self.terminal.default_palette[i];
- mask.unset(i);
+ var it = operations.constIterator(0);
- self.surfaceMessageWriter(.{ .color_change = .{
- .kind = .{ .palette = @intCast(i) },
- .color = self.terminal.color_palette.colors[i],
- } });
+ while (it.next()) |op| {
+ switch (op.*) {
+ .set => |set| {
+ switch (set.kind) {
+ .palette => |i| {
+ self.terminal.flags.dirty.palette = true;
+ self.terminal.color_palette.colors[i] = set.color;
+ self.terminal.color_palette.mask.set(i);
+ },
+ .foreground => {
+ self.foreground_color = set.color;
+ _ = self.renderer_mailbox.push(.{
+ .foreground_color = set.color,
+ }, .{ .forever = {} });
+ },
+ .background => {
+ self.background_color = set.color;
+ _ = self.renderer_mailbox.push(.{
+ .background_color = set.color,
+ }, .{ .forever = {} });
+ },
+ .cursor => {
+ self.cursor_color = set.color;
+ _ = self.renderer_mailbox.push(.{
+ .cursor_color = set.color,
+ }, .{ .forever = {} });
+ },
}
- } else {
- var it = std.mem.tokenizeScalar(u8, value, ';');
- while (it.next()) |param| {
- // Skip invalid parameters
- const i = std.fmt.parseUnsigned(u8, param, 10) catch continue;
- if (mask.isSet(i)) {
+
+ // Notify the surface of the color change
+ self.surfaceMessageWriter(.{ .color_change = .{
+ .kind = set.kind,
+ .color = set.color,
+ } });
+ },
+
+ .reset => |kind| {
+ switch (kind) {
+ .palette => |i| {
+ const mask = &self.terminal.color_palette.mask;
self.terminal.flags.dirty.palette = true;
self.terminal.color_palette.colors[i] = self.terminal.default_palette[i];
mask.unset(i);
+ self.surfaceMessageWriter(.{
+ .color_change = .{
+ .kind = .{ .palette = @intCast(i) },
+ .color = self.terminal.color_palette.colors[i],
+ },
+ });
+ },
+ .foreground => {
+ self.foreground_color = null;
+ _ = self.renderer_mailbox.push(.{
+ .foreground_color = self.foreground_color,
+ }, .{ .forever = {} });
+
self.surfaceMessageWriter(.{ .color_change = .{
- .kind = .{ .palette = @intCast(i) },
- .color = self.terminal.color_palette.colors[i],
+ .kind = .foreground,
+ .color = self.default_foreground_color,
} });
- }
+ },
+ .background => {
+ self.background_color = null;
+ _ = self.renderer_mailbox.push(.{
+ .background_color = self.background_color,
+ }, .{ .forever = {} });
+
+ self.surfaceMessageWriter(.{ .color_change = .{
+ .kind = .background,
+ .color = self.default_background_color,
+ } });
+ },
+ .cursor => {
+ self.cursor_color = null;
+
+ _ = self.renderer_mailbox.push(.{
+ .cursor_color = self.cursor_color,
+ }, .{ .forever = {} });
+
+ if (self.default_cursor_color) |color| {
+ self.surfaceMessageWriter(.{ .color_change = .{
+ .kind = .cursor,
+ .color = color,
+ } });
+ }
+ },
}
- }
- },
- .foreground => {
- self.foreground_color = null;
- _ = self.renderer_mailbox.push(.{
- .foreground_color = self.foreground_color,
- }, .{ .forever = {} });
-
- self.surfaceMessageWriter(.{ .color_change = .{
- .kind = .foreground,
- .color = self.default_foreground_color,
- } });
- },
- .background => {
- self.background_color = null;
- _ = self.renderer_mailbox.push(.{
- .background_color = self.background_color,
- }, .{ .forever = {} });
-
- self.surfaceMessageWriter(.{ .color_change = .{
- .kind = .background,
- .color = self.default_background_color,
- } });
- },
- .cursor => {
- self.cursor_color = null;
+ },
- _ = self.renderer_mailbox.push(.{
- .cursor_color = self.cursor_color,
- }, .{ .forever = {} });
+ .report => |kind| report: {
+ if (self.osc_color_report_format == .none) break :report;
- if (self.default_cursor_color) |color| {
- self.surfaceMessageWriter(.{ .color_change = .{
- .kind = .cursor,
- .color = color,
- } });
- }
- },
+ report = true;
+
+ const color = switch (kind) {
+ .palette => |i| self.terminal.color_palette.colors[i],
+ .foreground => self.foreground_color orelse self.default_foreground_color,
+ .background => self.background_color orelse self.default_background_color,
+ .cursor => self.cursor_color orelse
+ self.default_cursor_color orelse
+ self.foreground_color orelse
+ self.default_foreground_color,
+ };
+
+ switch (self.osc_color_report_format) {
+ .@"16-bit" => switch (kind) {
+ .palette => |i| try writer.print(
+ ";{d};rgb:{x:0>4}/{x:0>4}/{x:0>4}",
+ .{
+ i,
+ @as(u16, color.r) * 257,
+ @as(u16, color.g) * 257,
+ @as(u16, color.b) * 257,
+ },
+ ),
+ else => try writer.print(
+ ";rgb:{x:0>4}/{x:0>4}/{x:0>4}",
+ .{
+ @as(u16, color.r) * 257,
+ @as(u16, color.g) * 257,
+ @as(u16, color.b) * 257,
+ },
+ ),
+ },
+
+ .@"8-bit" => switch (kind) {
+ .palette => |i| try writer.print(
+ ";{d};rgb:{x:0>2}/{x:0>2}/{x:0>2}",
+ .{
+ i,
+ @as(u16, color.r),
+ @as(u16, color.g),
+ @as(u16, color.b),
+ },
+ ),
+ else => try writer.print(
+ ";rgb:{x:0>2}/{x:0>2}/{x:0>2}",
+ .{
+ @as(u16, color.r),
+ @as(u16, color.g),
+ @as(u16, color.b),
+ },
+ ),
+ },
+
+ .none => unreachable,
+ }
+ },
+ }
+ }
+ if (report) {
+ // If any of the operations were reports, finalize the report
+ // string and send it to the terminal.
+ try writer.writeAll(terminator.string());
+ const msg = try termio.Message.writeReq(self.alloc, response.items);
+ self.messageWriter(msg);
}
}
diff --git a/src/unicode/props.zig b/src/unicode/props.zig
index 8c7621b79..99c57aa0a 100644
--- a/src/unicode/props.zig
+++ b/src/unicode/props.zig
@@ -125,7 +125,7 @@ pub fn get(cp: u21) Properties {
return .{
.width = @intCast(@min(2, @max(0, zg_width))),
- .grapheme_boundary_class = GraphemeBoundaryClass.init(cp),
+ .grapheme_boundary_class = .init(cp),
};
}
diff --git a/typos.toml b/typos.toml
index 4f4bf7ee7..fafc38858 100644
--- a/typos.toml
+++ b/typos.toml
@@ -49,6 +49,8 @@ grey = "gray"
greyscale = "grayscale"
DECID = "DECID"
flate = "flate"
+typ = "typ"
+kend = "kend"
[type.po]
extend-glob = ["*.po"]
diff --git a/vendor/glad/include/glad/gl.h b/vendor/glad/include/glad/gl.h
index 2f71276dc..b9b398187 100644
--- a/vendor/glad/include/glad/gl.h
+++ b/vendor/glad/include/glad/gl.h
@@ -1,5 +1,5 @@
/**
- * Loader generated by glad 2.0.0 on Mon Oct 24 00:13:28 2022
+ * Loader generated by glad 2.0.8 on Mon May 19 01:37:34 2025
*
* SPDX-License-Identifier: (WTFPL OR CC0-1.0) AND Apache-2.0
*
@@ -8,7 +8,7 @@
* Extensions: 0
*
* APIs:
- * - gl:core=3.3
+ * - gl:core=4.3
*
* Options:
* - ALIAS = False
@@ -19,10 +19,10 @@
* - ON_DEMAND = False
*
* Commandline:
- * --api='gl:core=3.3' --extensions='' c --loader --mx
+ * --api='gl:core=4.3' --extensions='' c --loader --mx
*
* Online:
- * http://glad.sh/#api=gl%3Acore%3D3.3&extensions=&generator=c&options=LOADER%2CMX
+ * http://glad.sh/#api=gl%3Acore%3D4.3&extensions=&generator=c&options=LOADER%2CMX
*
*/
@@ -165,7 +165,7 @@ extern "C" {
#define GLAD_VERSION_MAJOR(version) (version / 10000)
#define GLAD_VERSION_MINOR(version) (version % 10000)
-#define GLAD_GENERATOR_VERSION "2.0.0"
+#define GLAD_GENERATOR_VERSION "2.0.8"
typedef void (*GLADapiproc)(void);
@@ -177,14 +177,25 @@ typedef void (*GLADpostcallback)(void *ret, const char *name, GLADapiproc apipro
#endif /* GLAD_PLATFORM_H_ */
+#define GL_ACTIVE_ATOMIC_COUNTER_BUFFERS 0x92D9
#define GL_ACTIVE_ATTRIBUTES 0x8B89
#define GL_ACTIVE_ATTRIBUTE_MAX_LENGTH 0x8B8A
+#define GL_ACTIVE_PROGRAM 0x8259
+#define GL_ACTIVE_RESOURCES 0x92F5
+#define GL_ACTIVE_SUBROUTINES 0x8DE5
+#define GL_ACTIVE_SUBROUTINE_MAX_LENGTH 0x8E48
+#define GL_ACTIVE_SUBROUTINE_UNIFORMS 0x8DE6
+#define GL_ACTIVE_SUBROUTINE_UNIFORM_LOCATIONS 0x8E47
+#define GL_ACTIVE_SUBROUTINE_UNIFORM_MAX_LENGTH 0x8E49
#define GL_ACTIVE_TEXTURE 0x84E0
#define GL_ACTIVE_UNIFORMS 0x8B86
#define GL_ACTIVE_UNIFORM_BLOCKS 0x8A36
#define GL_ACTIVE_UNIFORM_BLOCK_MAX_NAME_LENGTH 0x8A35
#define GL_ACTIVE_UNIFORM_MAX_LENGTH 0x8B87
+#define GL_ACTIVE_VARIABLES 0x9305
#define GL_ALIASED_LINE_WIDTH_RANGE 0x846E
+#define GL_ALL_BARRIER_BITS 0xFFFFFFFF
+#define GL_ALL_SHADER_BITS 0xFFFFFFFF
#define GL_ALPHA 0x1906
#define GL_ALREADY_SIGNALED 0x911A
#define GL_ALWAYS 0x0207
@@ -192,9 +203,28 @@ typedef void (*GLADpostcallback)(void *ret, const char *name, GLADapiproc apipro
#define GL_AND_INVERTED 0x1504
#define GL_AND_REVERSE 0x1502
#define GL_ANY_SAMPLES_PASSED 0x8C2F
+#define GL_ANY_SAMPLES_PASSED_CONSERVATIVE 0x8D6A
#define GL_ARRAY_BUFFER 0x8892
#define GL_ARRAY_BUFFER_BINDING 0x8894
+#define GL_ARRAY_SIZE 0x92FB
+#define GL_ARRAY_STRIDE 0x92FE
+#define GL_ATOMIC_COUNTER_BARRIER_BIT 0x00001000
+#define GL_ATOMIC_COUNTER_BUFFER 0x92C0
+#define GL_ATOMIC_COUNTER_BUFFER_ACTIVE_ATOMIC_COUNTERS 0x92C5
+#define GL_ATOMIC_COUNTER_BUFFER_ACTIVE_ATOMIC_COUNTER_INDICES 0x92C6
+#define GL_ATOMIC_COUNTER_BUFFER_BINDING 0x92C1
+#define GL_ATOMIC_COUNTER_BUFFER_DATA_SIZE 0x92C4
+#define GL_ATOMIC_COUNTER_BUFFER_INDEX 0x9301
+#define GL_ATOMIC_COUNTER_BUFFER_REFERENCED_BY_COMPUTE_SHADER 0x90ED
+#define GL_ATOMIC_COUNTER_BUFFER_REFERENCED_BY_FRAGMENT_SHADER 0x92CB
+#define GL_ATOMIC_COUNTER_BUFFER_REFERENCED_BY_GEOMETRY_SHADER 0x92CA
+#define GL_ATOMIC_COUNTER_BUFFER_REFERENCED_BY_TESS_CONTROL_SHADER 0x92C8
+#define GL_ATOMIC_COUNTER_BUFFER_REFERENCED_BY_TESS_EVALUATION_SHADER 0x92C9
+#define GL_ATOMIC_COUNTER_BUFFER_REFERENCED_BY_VERTEX_SHADER 0x92C7
+#define GL_ATOMIC_COUNTER_BUFFER_SIZE 0x92C3
+#define GL_ATOMIC_COUNTER_BUFFER_START 0x92C2
#define GL_ATTACHED_SHADERS 0x8B85
+#define GL_AUTO_GENERATE_MIPMAP 0x8295
#define GL_BACK 0x0405
#define GL_BACK_LEFT 0x0402
#define GL_BACK_RIGHT 0x0403
@@ -213,26 +243,34 @@ typedef void (*GLADpostcallback)(void *ret, const char *name, GLADapiproc apipro
#define GL_BLEND_SRC 0x0BE1
#define GL_BLEND_SRC_ALPHA 0x80CB
#define GL_BLEND_SRC_RGB 0x80C9
+#define GL_BLOCK_INDEX 0x92FD
#define GL_BLUE 0x1905
#define GL_BLUE_INTEGER 0x8D96
#define GL_BOOL 0x8B56
#define GL_BOOL_VEC2 0x8B57
#define GL_BOOL_VEC3 0x8B58
#define GL_BOOL_VEC4 0x8B59
+#define GL_BUFFER 0x82E0
#define GL_BUFFER_ACCESS 0x88BB
#define GL_BUFFER_ACCESS_FLAGS 0x911F
+#define GL_BUFFER_BINDING 0x9302
+#define GL_BUFFER_DATA_SIZE 0x9303
#define GL_BUFFER_MAPPED 0x88BC
#define GL_BUFFER_MAP_LENGTH 0x9120
#define GL_BUFFER_MAP_OFFSET 0x9121
#define GL_BUFFER_MAP_POINTER 0x88BD
#define GL_BUFFER_SIZE 0x8764
+#define GL_BUFFER_UPDATE_BARRIER_BIT 0x00000200
#define GL_BUFFER_USAGE 0x8765
+#define GL_BUFFER_VARIABLE 0x92E5
#define GL_BYTE 0x1400
+#define GL_CAVEAT_SUPPORT 0x82B8
#define GL_CCW 0x0901
#define GL_CLAMP_READ_COLOR 0x891C
#define GL_CLAMP_TO_BORDER 0x812D
#define GL_CLAMP_TO_EDGE 0x812F
#define GL_CLEAR 0x1500
+#define GL_CLEAR_BUFFER 0x82B4
#define GL_CLIP_DISTANCE0 0x3000
#define GL_CLIP_DISTANCE1 0x3001
#define GL_CLIP_DISTANCE2 0x3002
@@ -276,39 +314,93 @@ typedef void (*GLADpostcallback)(void *ret, const char *name, GLADapiproc apipro
#define GL_COLOR_ATTACHMENT9 0x8CE9
#define GL_COLOR_BUFFER_BIT 0x00004000
#define GL_COLOR_CLEAR_VALUE 0x0C22
+#define GL_COLOR_COMPONENTS 0x8283
+#define GL_COLOR_ENCODING 0x8296
#define GL_COLOR_LOGIC_OP 0x0BF2
+#define GL_COLOR_RENDERABLE 0x8286
#define GL_COLOR_WRITEMASK 0x0C23
+#define GL_COMMAND_BARRIER_BIT 0x00000040
#define GL_COMPARE_REF_TO_TEXTURE 0x884E
+#define GL_COMPATIBLE_SUBROUTINES 0x8E4B
#define GL_COMPILE_STATUS 0x8B81
+#define GL_COMPRESSED_R11_EAC 0x9270
#define GL_COMPRESSED_RED 0x8225
#define GL_COMPRESSED_RED_RGTC1 0x8DBB
#define GL_COMPRESSED_RG 0x8226
+#define GL_COMPRESSED_RG11_EAC 0x9272
#define GL_COMPRESSED_RGB 0x84ED
+#define GL_COMPRESSED_RGB8_ETC2 0x9274
+#define GL_COMPRESSED_RGB8_PUNCHTHROUGH_ALPHA1_ETC2 0x9276
#define GL_COMPRESSED_RGBA 0x84EE
+#define GL_COMPRESSED_RGBA8_ETC2_EAC 0x9278
+#define GL_COMPRESSED_RGBA_BPTC_UNORM 0x8E8C
+#define GL_COMPRESSED_RGB_BPTC_SIGNED_FLOAT 0x8E8E
+#define GL_COMPRESSED_RGB_BPTC_UNSIGNED_FLOAT 0x8E8F
#define GL_COMPRESSED_RG_RGTC2 0x8DBD
+#define GL_COMPRESSED_SIGNED_R11_EAC 0x9271
#define GL_COMPRESSED_SIGNED_RED_RGTC1 0x8DBC
+#define GL_COMPRESSED_SIGNED_RG11_EAC 0x9273
#define GL_COMPRESSED_SIGNED_RG_RGTC2 0x8DBE
#define GL_COMPRESSED_SRGB 0x8C48
+#define GL_COMPRESSED_SRGB8_ALPHA8_ETC2_EAC 0x9279
+#define GL_COMPRESSED_SRGB8_ETC2 0x9275
+#define GL_COMPRESSED_SRGB8_PUNCHTHROUGH_ALPHA1_ETC2 0x9277
#define GL_COMPRESSED_SRGB_ALPHA 0x8C49
+#define GL_COMPRESSED_SRGB_ALPHA_BPTC_UNORM 0x8E8D
#define GL_COMPRESSED_TEXTURE_FORMATS 0x86A3
+#define GL_COMPUTE_SHADER 0x91B9
+#define GL_COMPUTE_SHADER_BIT 0x00000020
+#define GL_COMPUTE_SUBROUTINE 0x92ED
+#define GL_COMPUTE_SUBROUTINE_UNIFORM 0x92F3
+#define GL_COMPUTE_TEXTURE 0x82A0
+#define GL_COMPUTE_WORK_GROUP_SIZE 0x8267
#define GL_CONDITION_SATISFIED 0x911C
#define GL_CONSTANT_ALPHA 0x8003
#define GL_CONSTANT_COLOR 0x8001
#define GL_CONTEXT_COMPATIBILITY_PROFILE_BIT 0x00000002
#define GL_CONTEXT_CORE_PROFILE_BIT 0x00000001
#define GL_CONTEXT_FLAGS 0x821E
+#define GL_CONTEXT_FLAG_DEBUG_BIT 0x00000002
#define GL_CONTEXT_FLAG_FORWARD_COMPATIBLE_BIT 0x00000001
#define GL_CONTEXT_PROFILE_MASK 0x9126
#define GL_COPY 0x1503
#define GL_COPY_INVERTED 0x150C
#define GL_COPY_READ_BUFFER 0x8F36
+#define GL_COPY_READ_BUFFER_BINDING 0x8F36
#define GL_COPY_WRITE_BUFFER 0x8F37
+#define GL_COPY_WRITE_BUFFER_BINDING 0x8F37
#define GL_CULL_FACE 0x0B44
#define GL_CULL_FACE_MODE 0x0B45
#define GL_CURRENT_PROGRAM 0x8B8D
#define GL_CURRENT_QUERY 0x8865
#define GL_CURRENT_VERTEX_ATTRIB 0x8626
#define GL_CW 0x0900
+#define GL_DEBUG_CALLBACK_FUNCTION 0x8244
+#define GL_DEBUG_CALLBACK_USER_PARAM 0x8245
+#define GL_DEBUG_GROUP_STACK_DEPTH 0x826D
+#define GL_DEBUG_LOGGED_MESSAGES 0x9145
+#define GL_DEBUG_NEXT_LOGGED_MESSAGE_LENGTH 0x8243
+#define GL_DEBUG_OUTPUT 0x92E0
+#define GL_DEBUG_OUTPUT_SYNCHRONOUS 0x8242
+#define GL_DEBUG_SEVERITY_HIGH 0x9146
+#define GL_DEBUG_SEVERITY_LOW 0x9148
+#define GL_DEBUG_SEVERITY_MEDIUM 0x9147
+#define GL_DEBUG_SEVERITY_NOTIFICATION 0x826B
+#define GL_DEBUG_SOURCE_API 0x8246
+#define GL_DEBUG_SOURCE_APPLICATION 0x824A
+#define GL_DEBUG_SOURCE_OTHER 0x824B
+#define GL_DEBUG_SOURCE_SHADER_COMPILER 0x8248
+#define GL_DEBUG_SOURCE_THIRD_PARTY 0x8249
+#define GL_DEBUG_SOURCE_WINDOW_SYSTEM 0x8247
+#define GL_DEBUG_TYPE_DEPRECATED_BEHAVIOR 0x824D
+#define GL_DEBUG_TYPE_ERROR 0x824C
+#define GL_DEBUG_TYPE_MARKER 0x8268
+#define GL_DEBUG_TYPE_OTHER 0x8251
+#define GL_DEBUG_TYPE_PERFORMANCE 0x8250
+#define GL_DEBUG_TYPE_POP_GROUP 0x826A
+#define GL_DEBUG_TYPE_PORTABILITY 0x824F
+#define GL_DEBUG_TYPE_PUSH_GROUP 0x8269
+#define GL_DEBUG_TYPE_UNDEFINED_BEHAVIOR 0x824E
#define GL_DECR 0x1E03
#define GL_DECR_WRAP 0x8508
#define GL_DELETE_STATUS 0x8B80
@@ -324,16 +416,33 @@ typedef void (*GLADpostcallback)(void *ret, const char *name, GLADapiproc apipro
#define GL_DEPTH_COMPONENT24 0x81A6
#define GL_DEPTH_COMPONENT32 0x81A7
#define GL_DEPTH_COMPONENT32F 0x8CAC
+#define GL_DEPTH_COMPONENTS 0x8284
#define GL_DEPTH_FUNC 0x0B74
#define GL_DEPTH_RANGE 0x0B70
+#define GL_DEPTH_RENDERABLE 0x8287
#define GL_DEPTH_STENCIL 0x84F9
#define GL_DEPTH_STENCIL_ATTACHMENT 0x821A
+#define GL_DEPTH_STENCIL_TEXTURE_MODE 0x90EA
#define GL_DEPTH_TEST 0x0B71
#define GL_DEPTH_WRITEMASK 0x0B72
+#define GL_DISPATCH_INDIRECT_BUFFER 0x90EE
+#define GL_DISPATCH_INDIRECT_BUFFER_BINDING 0x90EF
#define GL_DITHER 0x0BD0
#define GL_DONT_CARE 0x1100
#define GL_DOUBLE 0x140A
#define GL_DOUBLEBUFFER 0x0C32
+#define GL_DOUBLE_MAT2 0x8F46
+#define GL_DOUBLE_MAT2x3 0x8F49
+#define GL_DOUBLE_MAT2x4 0x8F4A
+#define GL_DOUBLE_MAT3 0x8F47
+#define GL_DOUBLE_MAT3x2 0x8F4B
+#define GL_DOUBLE_MAT3x4 0x8F4C
+#define GL_DOUBLE_MAT4 0x8F48
+#define GL_DOUBLE_MAT4x2 0x8F4D
+#define GL_DOUBLE_MAT4x3 0x8F4E
+#define GL_DOUBLE_VEC2 0x8FFC
+#define GL_DOUBLE_VEC3 0x8FFD
+#define GL_DOUBLE_VEC4 0x8FFE
#define GL_DRAW_BUFFER 0x0C01
#define GL_DRAW_BUFFER0 0x8825
#define GL_DRAW_BUFFER1 0x8826
@@ -353,11 +462,14 @@ typedef void (*GLADpostcallback)(void *ret, const char *name, GLADapiproc apipro
#define GL_DRAW_BUFFER9 0x882E
#define GL_DRAW_FRAMEBUFFER 0x8CA9
#define GL_DRAW_FRAMEBUFFER_BINDING 0x8CA6
+#define GL_DRAW_INDIRECT_BUFFER 0x8F3F
+#define GL_DRAW_INDIRECT_BUFFER_BINDING 0x8F43
#define GL_DST_ALPHA 0x0304
#define GL_DST_COLOR 0x0306
#define GL_DYNAMIC_COPY 0x88EA
#define GL_DYNAMIC_DRAW 0x88E8
#define GL_DYNAMIC_READ 0x88E9
+#define GL_ELEMENT_ARRAY_BARRIER_BIT 0x00000002
#define GL_ELEMENT_ARRAY_BUFFER 0x8893
#define GL_ELEMENT_ARRAY_BUFFER_BINDING 0x8895
#define GL_EQUAL 0x0202
@@ -366,7 +478,9 @@ typedef void (*GLADpostcallback)(void *ret, const char *name, GLADapiproc apipro
#define GL_FALSE 0
#define GL_FASTEST 0x1101
#define GL_FILL 0x1B02
+#define GL_FILTER 0x829A
#define GL_FIRST_VERTEX_CONVENTION 0x8E4D
+#define GL_FIXED 0x140C
#define GL_FIXED_ONLY 0x891D
#define GL_FLOAT 0x1406
#define GL_FLOAT_32_UNSIGNED_INT_24_8_REV 0x8DAD
@@ -382,8 +496,15 @@ typedef void (*GLADpostcallback)(void *ret, const char *name, GLADapiproc apipro
#define GL_FLOAT_VEC2 0x8B50
#define GL_FLOAT_VEC3 0x8B51
#define GL_FLOAT_VEC4 0x8B52
+#define GL_FRACTIONAL_EVEN 0x8E7C
+#define GL_FRACTIONAL_ODD 0x8E7B
+#define GL_FRAGMENT_INTERPOLATION_OFFSET_BITS 0x8E5D
#define GL_FRAGMENT_SHADER 0x8B30
+#define GL_FRAGMENT_SHADER_BIT 0x00000002
#define GL_FRAGMENT_SHADER_DERIVATIVE_HINT 0x8B8B
+#define GL_FRAGMENT_SUBROUTINE 0x92EC
+#define GL_FRAGMENT_SUBROUTINE_UNIFORM 0x92F2
+#define GL_FRAGMENT_TEXTURE 0x829F
#define GL_FRAMEBUFFER 0x8D40
#define GL_FRAMEBUFFER_ATTACHMENT_ALPHA_SIZE 0x8215
#define GL_FRAMEBUFFER_ATTACHMENT_BLUE_SIZE 0x8214
@@ -399,15 +520,24 @@ typedef void (*GLADpostcallback)(void *ret, const char *name, GLADapiproc apipro
#define GL_FRAMEBUFFER_ATTACHMENT_TEXTURE_CUBE_MAP_FACE 0x8CD3
#define GL_FRAMEBUFFER_ATTACHMENT_TEXTURE_LAYER 0x8CD4
#define GL_FRAMEBUFFER_ATTACHMENT_TEXTURE_LEVEL 0x8CD2
+#define GL_FRAMEBUFFER_BARRIER_BIT 0x00000400
#define GL_FRAMEBUFFER_BINDING 0x8CA6
+#define GL_FRAMEBUFFER_BLEND 0x828B
#define GL_FRAMEBUFFER_COMPLETE 0x8CD5
#define GL_FRAMEBUFFER_DEFAULT 0x8218
+#define GL_FRAMEBUFFER_DEFAULT_FIXED_SAMPLE_LOCATIONS 0x9314
+#define GL_FRAMEBUFFER_DEFAULT_HEIGHT 0x9311
+#define GL_FRAMEBUFFER_DEFAULT_LAYERS 0x9312
+#define GL_FRAMEBUFFER_DEFAULT_SAMPLES 0x9313
+#define GL_FRAMEBUFFER_DEFAULT_WIDTH 0x9310
#define GL_FRAMEBUFFER_INCOMPLETE_ATTACHMENT 0x8CD6
#define GL_FRAMEBUFFER_INCOMPLETE_DRAW_BUFFER 0x8CDB
#define GL_FRAMEBUFFER_INCOMPLETE_LAYER_TARGETS 0x8DA8
#define GL_FRAMEBUFFER_INCOMPLETE_MISSING_ATTACHMENT 0x8CD7
#define GL_FRAMEBUFFER_INCOMPLETE_MULTISAMPLE 0x8D56
#define GL_FRAMEBUFFER_INCOMPLETE_READ_BUFFER 0x8CDC
+#define GL_FRAMEBUFFER_RENDERABLE 0x8289
+#define GL_FRAMEBUFFER_RENDERABLE_LAYERED 0x828A
#define GL_FRAMEBUFFER_SRGB 0x8DB9
#define GL_FRAMEBUFFER_UNDEFINED 0x8219
#define GL_FRAMEBUFFER_UNSUPPORTED 0x8CDD
@@ -416,24 +546,97 @@ typedef void (*GLADpostcallback)(void *ret, const char *name, GLADapiproc apipro
#define GL_FRONT_FACE 0x0B46
#define GL_FRONT_LEFT 0x0400
#define GL_FRONT_RIGHT 0x0401
+#define GL_FULL_SUPPORT 0x82B7
#define GL_FUNC_ADD 0x8006
#define GL_FUNC_REVERSE_SUBTRACT 0x800B
#define GL_FUNC_SUBTRACT 0x800A
#define GL_GEOMETRY_INPUT_TYPE 0x8917
#define GL_GEOMETRY_OUTPUT_TYPE 0x8918
#define GL_GEOMETRY_SHADER 0x8DD9
+#define GL_GEOMETRY_SHADER_BIT 0x00000004
+#define GL_GEOMETRY_SHADER_INVOCATIONS 0x887F
+#define GL_GEOMETRY_SUBROUTINE 0x92EB
+#define GL_GEOMETRY_SUBROUTINE_UNIFORM 0x92F1
+#define GL_GEOMETRY_TEXTURE 0x829E
#define GL_GEOMETRY_VERTICES_OUT 0x8916
#define GL_GEQUAL 0x0206
+#define GL_GET_TEXTURE_IMAGE_FORMAT 0x8291
+#define GL_GET_TEXTURE_IMAGE_TYPE 0x8292
#define GL_GREATER 0x0204
#define GL_GREEN 0x1904
#define GL_GREEN_INTEGER 0x8D95
#define GL_HALF_FLOAT 0x140B
+#define GL_HIGH_FLOAT 0x8DF2
+#define GL_HIGH_INT 0x8DF5
+#define GL_IMAGE_1D 0x904C
+#define GL_IMAGE_1D_ARRAY 0x9052
+#define GL_IMAGE_2D 0x904D
+#define GL_IMAGE_2D_ARRAY 0x9053
+#define GL_IMAGE_2D_MULTISAMPLE 0x9055
+#define GL_IMAGE_2D_MULTISAMPLE_ARRAY 0x9056
+#define GL_IMAGE_2D_RECT 0x904F
+#define GL_IMAGE_3D 0x904E
+#define GL_IMAGE_BINDING_ACCESS 0x8F3E
+#define GL_IMAGE_BINDING_FORMAT 0x906E
+#define GL_IMAGE_BINDING_LAYER 0x8F3D
+#define GL_IMAGE_BINDING_LAYERED 0x8F3C
+#define GL_IMAGE_BINDING_LEVEL 0x8F3B
+#define GL_IMAGE_BINDING_NAME 0x8F3A
+#define GL_IMAGE_BUFFER 0x9051
+#define GL_IMAGE_CLASS_10_10_10_2 0x82C3
+#define GL_IMAGE_CLASS_11_11_10 0x82C2
+#define GL_IMAGE_CLASS_1_X_16 0x82BE
+#define GL_IMAGE_CLASS_1_X_32 0x82BB
+#define GL_IMAGE_CLASS_1_X_8 0x82C1
+#define GL_IMAGE_CLASS_2_X_16 0x82BD
+#define GL_IMAGE_CLASS_2_X_32 0x82BA
+#define GL_IMAGE_CLASS_2_X_8 0x82C0
+#define GL_IMAGE_CLASS_4_X_16 0x82BC
+#define GL_IMAGE_CLASS_4_X_32 0x82B9
+#define GL_IMAGE_CLASS_4_X_8 0x82BF
+#define GL_IMAGE_COMPATIBILITY_CLASS 0x82A8
+#define GL_IMAGE_CUBE 0x9050
+#define GL_IMAGE_CUBE_MAP_ARRAY 0x9054
+#define GL_IMAGE_FORMAT_COMPATIBILITY_BY_CLASS 0x90C9
+#define GL_IMAGE_FORMAT_COMPATIBILITY_BY_SIZE 0x90C8
+#define GL_IMAGE_FORMAT_COMPATIBILITY_TYPE 0x90C7
+#define GL_IMAGE_PIXEL_FORMAT 0x82A9
+#define GL_IMAGE_PIXEL_TYPE 0x82AA
+#define GL_IMAGE_TEXEL_SIZE 0x82A7
+#define GL_IMPLEMENTATION_COLOR_READ_FORMAT 0x8B9B
+#define GL_IMPLEMENTATION_COLOR_READ_TYPE 0x8B9A
#define GL_INCR 0x1E02
#define GL_INCR_WRAP 0x8507
#define GL_INFO_LOG_LENGTH 0x8B84
#define GL_INT 0x1404
#define GL_INTERLEAVED_ATTRIBS 0x8C8C
+#define GL_INTERNALFORMAT_ALPHA_SIZE 0x8274
+#define GL_INTERNALFORMAT_ALPHA_TYPE 0x827B
+#define GL_INTERNALFORMAT_BLUE_SIZE 0x8273
+#define GL_INTERNALFORMAT_BLUE_TYPE 0x827A
+#define GL_INTERNALFORMAT_DEPTH_SIZE 0x8275
+#define GL_INTERNALFORMAT_DEPTH_TYPE 0x827C
+#define GL_INTERNALFORMAT_GREEN_SIZE 0x8272
+#define GL_INTERNALFORMAT_GREEN_TYPE 0x8279
+#define GL_INTERNALFORMAT_PREFERRED 0x8270
+#define GL_INTERNALFORMAT_RED_SIZE 0x8271
+#define GL_INTERNALFORMAT_RED_TYPE 0x8278
+#define GL_INTERNALFORMAT_SHARED_SIZE 0x8277
+#define GL_INTERNALFORMAT_STENCIL_SIZE 0x8276
+#define GL_INTERNALFORMAT_STENCIL_TYPE 0x827D
+#define GL_INTERNALFORMAT_SUPPORTED 0x826F
#define GL_INT_2_10_10_10_REV 0x8D9F
+#define GL_INT_IMAGE_1D 0x9057
+#define GL_INT_IMAGE_1D_ARRAY 0x905D
+#define GL_INT_IMAGE_2D 0x9058
+#define GL_INT_IMAGE_2D_ARRAY 0x905E
+#define GL_INT_IMAGE_2D_MULTISAMPLE 0x9060
+#define GL_INT_IMAGE_2D_MULTISAMPLE_ARRAY 0x9061
+#define GL_INT_IMAGE_2D_RECT 0x905A
+#define GL_INT_IMAGE_3D 0x9059
+#define GL_INT_IMAGE_BUFFER 0x905C
+#define GL_INT_IMAGE_CUBE 0x905B
+#define GL_INT_IMAGE_CUBE_MAP_ARRAY 0x905F
#define GL_INT_SAMPLER_1D 0x8DC9
#define GL_INT_SAMPLER_1D_ARRAY 0x8DCE
#define GL_INT_SAMPLER_2D 0x8DCA
@@ -444,6 +647,7 @@ typedef void (*GLADpostcallback)(void *ret, const char *name, GLADapiproc apipro
#define GL_INT_SAMPLER_3D 0x8DCB
#define GL_INT_SAMPLER_BUFFER 0x8DD0
#define GL_INT_SAMPLER_CUBE 0x8DCC
+#define GL_INT_SAMPLER_CUBE_MAP_ARRAY 0x900E
#define GL_INT_VEC2 0x8B53
#define GL_INT_VEC3 0x8B54
#define GL_INT_VEC4 0x8B55
@@ -453,8 +657,12 @@ typedef void (*GLADpostcallback)(void *ret, const char *name, GLADapiproc apipro
#define GL_INVALID_OPERATION 0x0502
#define GL_INVALID_VALUE 0x0501
#define GL_INVERT 0x150A
+#define GL_ISOLINES 0x8E7A
+#define GL_IS_PER_PATCH 0x92E7
+#define GL_IS_ROW_MAJOR 0x9300
#define GL_KEEP 0x1E00
#define GL_LAST_VERTEX_CONVENTION 0x8E4E
+#define GL_LAYER_PROVOKING_VERTEX 0x825E
#define GL_LEFT 0x0406
#define GL_LEQUAL 0x0203
#define GL_LESS 0x0201
@@ -473,71 +681,176 @@ typedef void (*GLADpostcallback)(void *ret, const char *name, GLADapiproc apipro
#define GL_LINE_WIDTH_GRANULARITY 0x0B23
#define GL_LINE_WIDTH_RANGE 0x0B22
#define GL_LINK_STATUS 0x8B82
+#define GL_LOCATION 0x930E
+#define GL_LOCATION_INDEX 0x930F
#define GL_LOGIC_OP_MODE 0x0BF0
#define GL_LOWER_LEFT 0x8CA1
+#define GL_LOW_FLOAT 0x8DF0
+#define GL_LOW_INT 0x8DF3
#define GL_MAJOR_VERSION 0x821B
+#define GL_MANUAL_GENERATE_MIPMAP 0x8294
#define GL_MAP_FLUSH_EXPLICIT_BIT 0x0010
#define GL_MAP_INVALIDATE_BUFFER_BIT 0x0008
#define GL_MAP_INVALIDATE_RANGE_BIT 0x0004
#define GL_MAP_READ_BIT 0x0001
#define GL_MAP_UNSYNCHRONIZED_BIT 0x0020
#define GL_MAP_WRITE_BIT 0x0002
+#define GL_MATRIX_STRIDE 0x92FF
#define GL_MAX 0x8008
#define GL_MAX_3D_TEXTURE_SIZE 0x8073
#define GL_MAX_ARRAY_TEXTURE_LAYERS 0x88FF
+#define GL_MAX_ATOMIC_COUNTER_BUFFER_BINDINGS 0x92DC
+#define GL_MAX_ATOMIC_COUNTER_BUFFER_SIZE 0x92D8
#define GL_MAX_CLIP_DISTANCES 0x0D32
#define GL_MAX_COLOR_ATTACHMENTS 0x8CDF
#define GL_MAX_COLOR_TEXTURE_SAMPLES 0x910E
+#define GL_MAX_COMBINED_ATOMIC_COUNTERS 0x92D7
+#define GL_MAX_COMBINED_ATOMIC_COUNTER_BUFFERS 0x92D1
+#define GL_MAX_COMBINED_COMPUTE_UNIFORM_COMPONENTS 0x8266
+#define GL_MAX_COMBINED_DIMENSIONS 0x8282
#define GL_MAX_COMBINED_FRAGMENT_UNIFORM_COMPONENTS 0x8A33
#define GL_MAX_COMBINED_GEOMETRY_UNIFORM_COMPONENTS 0x8A32
+#define GL_MAX_COMBINED_IMAGE_UNIFORMS 0x90CF
+#define GL_MAX_COMBINED_IMAGE_UNITS_AND_FRAGMENT_OUTPUTS 0x8F39
+#define GL_MAX_COMBINED_SHADER_OUTPUT_RESOURCES 0x8F39
+#define GL_MAX_COMBINED_SHADER_STORAGE_BLOCKS 0x90DC
+#define GL_MAX_COMBINED_TESS_CONTROL_UNIFORM_COMPONENTS 0x8E1E
+#define GL_MAX_COMBINED_TESS_EVALUATION_UNIFORM_COMPONENTS 0x8E1F
#define GL_MAX_COMBINED_TEXTURE_IMAGE_UNITS 0x8B4D
#define GL_MAX_COMBINED_UNIFORM_BLOCKS 0x8A2E
#define GL_MAX_COMBINED_VERTEX_UNIFORM_COMPONENTS 0x8A31
+#define GL_MAX_COMPUTE_ATOMIC_COUNTERS 0x8265
+#define GL_MAX_COMPUTE_ATOMIC_COUNTER_BUFFERS 0x8264
+#define GL_MAX_COMPUTE_IMAGE_UNIFORMS 0x91BD
+#define GL_MAX_COMPUTE_SHADER_STORAGE_BLOCKS 0x90DB
+#define GL_MAX_COMPUTE_SHARED_MEMORY_SIZE 0x8262
+#define GL_MAX_COMPUTE_TEXTURE_IMAGE_UNITS 0x91BC
+#define GL_MAX_COMPUTE_UNIFORM_BLOCKS 0x91BB
+#define GL_MAX_COMPUTE_UNIFORM_COMPONENTS 0x8263
+#define GL_MAX_COMPUTE_WORK_GROUP_COUNT 0x91BE
+#define GL_MAX_COMPUTE_WORK_GROUP_INVOCATIONS 0x90EB
+#define GL_MAX_COMPUTE_WORK_GROUP_SIZE 0x91BF
#define GL_MAX_CUBE_MAP_TEXTURE_SIZE 0x851C
+#define GL_MAX_DEBUG_GROUP_STACK_DEPTH 0x826C
+#define GL_MAX_DEBUG_LOGGED_MESSAGES 0x9144
+#define GL_MAX_DEBUG_MESSAGE_LENGTH 0x9143
+#define GL_MAX_DEPTH 0x8280
#define GL_MAX_DEPTH_TEXTURE_SAMPLES 0x910F
#define GL_MAX_DRAW_BUFFERS 0x8824
#define GL_MAX_DUAL_SOURCE_DRAW_BUFFERS 0x88FC
#define GL_MAX_ELEMENTS_INDICES 0x80E9
#define GL_MAX_ELEMENTS_VERTICES 0x80E8
+#define GL_MAX_ELEMENT_INDEX 0x8D6B
+#define GL_MAX_FRAGMENT_ATOMIC_COUNTERS 0x92D6
+#define GL_MAX_FRAGMENT_ATOMIC_COUNTER_BUFFERS 0x92D0
+#define GL_MAX_FRAGMENT_IMAGE_UNIFORMS 0x90CE
#define GL_MAX_FRAGMENT_INPUT_COMPONENTS 0x9125
+#define GL_MAX_FRAGMENT_INTERPOLATION_OFFSET 0x8E5C
+#define GL_MAX_FRAGMENT_SHADER_STORAGE_BLOCKS 0x90DA
#define GL_MAX_FRAGMENT_UNIFORM_BLOCKS 0x8A2D
#define GL_MAX_FRAGMENT_UNIFORM_COMPONENTS 0x8B49
+#define GL_MAX_FRAGMENT_UNIFORM_VECTORS 0x8DFD
+#define GL_MAX_FRAMEBUFFER_HEIGHT 0x9316
+#define GL_MAX_FRAMEBUFFER_LAYERS 0x9317
+#define GL_MAX_FRAMEBUFFER_SAMPLES 0x9318
+#define GL_MAX_FRAMEBUFFER_WIDTH 0x9315
+#define GL_MAX_GEOMETRY_ATOMIC_COUNTERS 0x92D5
+#define GL_MAX_GEOMETRY_ATOMIC_COUNTER_BUFFERS 0x92CF
+#define GL_MAX_GEOMETRY_IMAGE_UNIFORMS 0x90CD
#define GL_MAX_GEOMETRY_INPUT_COMPONENTS 0x9123
#define GL_MAX_GEOMETRY_OUTPUT_COMPONENTS 0x9124
#define GL_MAX_GEOMETRY_OUTPUT_VERTICES 0x8DE0
+#define GL_MAX_GEOMETRY_SHADER_INVOCATIONS 0x8E5A
+#define GL_MAX_GEOMETRY_SHADER_STORAGE_BLOCKS 0x90D7
#define GL_MAX_GEOMETRY_TEXTURE_IMAGE_UNITS 0x8C29
#define GL_MAX_GEOMETRY_TOTAL_OUTPUT_COMPONENTS 0x8DE1
#define GL_MAX_GEOMETRY_UNIFORM_BLOCKS 0x8A2C
#define GL_MAX_GEOMETRY_UNIFORM_COMPONENTS 0x8DDF
+#define GL_MAX_HEIGHT 0x827F
+#define GL_MAX_IMAGE_SAMPLES 0x906D
+#define GL_MAX_IMAGE_UNITS 0x8F38
#define GL_MAX_INTEGER_SAMPLES 0x9110
+#define GL_MAX_LABEL_LENGTH 0x82E8
+#define GL_MAX_LAYERS 0x8281
+#define GL_MAX_NAME_LENGTH 0x92F6
+#define GL_MAX_NUM_ACTIVE_VARIABLES 0x92F7
+#define GL_MAX_NUM_COMPATIBLE_SUBROUTINES 0x92F8
+#define GL_MAX_PATCH_VERTICES 0x8E7D
#define GL_MAX_PROGRAM_TEXEL_OFFSET 0x8905
+#define GL_MAX_PROGRAM_TEXTURE_GATHER_OFFSET 0x8E5F
#define GL_MAX_RECTANGLE_TEXTURE_SIZE 0x84F8
#define GL_MAX_RENDERBUFFER_SIZE 0x84E8
#define GL_MAX_SAMPLES 0x8D57
#define GL_MAX_SAMPLE_MASK_WORDS 0x8E59
#define GL_MAX_SERVER_WAIT_TIMEOUT 0x9111
+#define GL_MAX_SHADER_STORAGE_BLOCK_SIZE 0x90DE
+#define GL_MAX_SHADER_STORAGE_BUFFER_BINDINGS 0x90DD
+#define GL_MAX_SUBROUTINES 0x8DE7
+#define GL_MAX_SUBROUTINE_UNIFORM_LOCATIONS 0x8DE8
+#define GL_MAX_TESS_CONTROL_ATOMIC_COUNTERS 0x92D3
+#define GL_MAX_TESS_CONTROL_ATOMIC_COUNTER_BUFFERS 0x92CD
+#define GL_MAX_TESS_CONTROL_IMAGE_UNIFORMS 0x90CB
+#define GL_MAX_TESS_CONTROL_INPUT_COMPONENTS 0x886C
+#define GL_MAX_TESS_CONTROL_OUTPUT_COMPONENTS 0x8E83
+#define GL_MAX_TESS_CONTROL_SHADER_STORAGE_BLOCKS 0x90D8
+#define GL_MAX_TESS_CONTROL_TEXTURE_IMAGE_UNITS 0x8E81
+#define GL_MAX_TESS_CONTROL_TOTAL_OUTPUT_COMPONENTS 0x8E85
+#define GL_MAX_TESS_CONTROL_UNIFORM_BLOCKS 0x8E89
+#define GL_MAX_TESS_CONTROL_UNIFORM_COMPONENTS 0x8E7F
+#define GL_MAX_TESS_EVALUATION_ATOMIC_COUNTERS 0x92D4
+#define GL_MAX_TESS_EVALUATION_ATOMIC_COUNTER_BUFFERS 0x92CE
+#define GL_MAX_TESS_EVALUATION_IMAGE_UNIFORMS 0x90CC
+#define GL_MAX_TESS_EVALUATION_INPUT_COMPONENTS 0x886D
+#define GL_MAX_TESS_EVALUATION_OUTPUT_COMPONENTS 0x8E86
+#define GL_MAX_TESS_EVALUATION_SHADER_STORAGE_BLOCKS 0x90D9
+#define GL_MAX_TESS_EVALUATION_TEXTURE_IMAGE_UNITS 0x8E82
+#define GL_MAX_TESS_EVALUATION_UNIFORM_BLOCKS 0x8E8A
+#define GL_MAX_TESS_EVALUATION_UNIFORM_COMPONENTS 0x8E80
+#define GL_MAX_TESS_GEN_LEVEL 0x8E7E
+#define GL_MAX_TESS_PATCH_COMPONENTS 0x8E84
#define GL_MAX_TEXTURE_BUFFER_SIZE 0x8C2B
#define GL_MAX_TEXTURE_IMAGE_UNITS 0x8872
#define GL_MAX_TEXTURE_LOD_BIAS 0x84FD
#define GL_MAX_TEXTURE_SIZE 0x0D33
+#define GL_MAX_TRANSFORM_FEEDBACK_BUFFERS 0x8E70
#define GL_MAX_TRANSFORM_FEEDBACK_INTERLEAVED_COMPONENTS 0x8C8A
#define GL_MAX_TRANSFORM_FEEDBACK_SEPARATE_ATTRIBS 0x8C8B
#define GL_MAX_TRANSFORM_FEEDBACK_SEPARATE_COMPONENTS 0x8C80
#define GL_MAX_UNIFORM_BLOCK_SIZE 0x8A30
#define GL_MAX_UNIFORM_BUFFER_BINDINGS 0x8A2F
+#define GL_MAX_UNIFORM_LOCATIONS 0x826E
#define GL_MAX_VARYING_COMPONENTS 0x8B4B
#define GL_MAX_VARYING_FLOATS 0x8B4B
+#define GL_MAX_VARYING_VECTORS 0x8DFC
+#define GL_MAX_VERTEX_ATOMIC_COUNTERS 0x92D2
+#define GL_MAX_VERTEX_ATOMIC_COUNTER_BUFFERS 0x92CC
#define GL_MAX_VERTEX_ATTRIBS 0x8869
+#define GL_MAX_VERTEX_ATTRIB_BINDINGS 0x82DA
+#define GL_MAX_VERTEX_ATTRIB_RELATIVE_OFFSET 0x82D9
+#define GL_MAX_VERTEX_IMAGE_UNIFORMS 0x90CA
#define GL_MAX_VERTEX_OUTPUT_COMPONENTS 0x9122
+#define GL_MAX_VERTEX_SHADER_STORAGE_BLOCKS 0x90D6
+#define GL_MAX_VERTEX_STREAMS 0x8E71
#define GL_MAX_VERTEX_TEXTURE_IMAGE_UNITS 0x8B4C
#define GL_MAX_VERTEX_UNIFORM_BLOCKS 0x8A2B
#define GL_MAX_VERTEX_UNIFORM_COMPONENTS 0x8B4A
+#define GL_MAX_VERTEX_UNIFORM_VECTORS 0x8DFB
+#define GL_MAX_VIEWPORTS 0x825B
#define GL_MAX_VIEWPORT_DIMS 0x0D3A
+#define GL_MAX_WIDTH 0x827E
+#define GL_MEDIUM_FLOAT 0x8DF1
+#define GL_MEDIUM_INT 0x8DF4
#define GL_MIN 0x8007
#define GL_MINOR_VERSION 0x821C
+#define GL_MIN_FRAGMENT_INTERPOLATION_OFFSET 0x8E5B
+#define GL_MIN_MAP_BUFFER_ALIGNMENT 0x90BC
#define GL_MIN_PROGRAM_TEXEL_OFFSET 0x8904
+#define GL_MIN_PROGRAM_TEXTURE_GATHER_OFFSET 0x8E5E
+#define GL_MIN_SAMPLE_SHADING_VALUE 0x8C37
+#define GL_MIPMAP 0x8293
#define GL_MIRRORED_REPEAT 0x8370
#define GL_MULTISAMPLE 0x809D
+#define GL_NAME_LENGTH 0x92F9
#define GL_NAND 0x150E
#define GL_NEAREST 0x2600
#define GL_NEAREST_MIPMAP_LINEAR 0x2702
@@ -549,9 +862,16 @@ typedef void (*GLADpostcallback)(void *ret, const char *name, GLADapiproc apipro
#define GL_NOR 0x1508
#define GL_NOTEQUAL 0x0205
#define GL_NO_ERROR 0
+#define GL_NUM_ACTIVE_VARIABLES 0x9304
+#define GL_NUM_COMPATIBLE_SUBROUTINES 0x8E4A
#define GL_NUM_COMPRESSED_TEXTURE_FORMATS 0x86A2
#define GL_NUM_EXTENSIONS 0x821D
+#define GL_NUM_PROGRAM_BINARY_FORMATS 0x87FE
+#define GL_NUM_SAMPLE_COUNTS 0x9380
+#define GL_NUM_SHADER_BINARY_FORMATS 0x8DF9
+#define GL_NUM_SHADING_LANGUAGE_VERSIONS 0x82E9
#define GL_OBJECT_TYPE 0x9112
+#define GL_OFFSET 0x92FC
#define GL_ONE 1
#define GL_ONE_MINUS_CONSTANT_ALPHA 0x8004
#define GL_ONE_MINUS_CONSTANT_COLOR 0x8002
@@ -566,6 +886,10 @@ typedef void (*GLADpostcallback)(void *ret, const char *name, GLADapiproc apipro
#define GL_OR_REVERSE 0x150B
#define GL_OUT_OF_MEMORY 0x0505
#define GL_PACK_ALIGNMENT 0x0D05
+#define GL_PACK_COMPRESSED_BLOCK_DEPTH 0x912D
+#define GL_PACK_COMPRESSED_BLOCK_HEIGHT 0x912C
+#define GL_PACK_COMPRESSED_BLOCK_SIZE 0x912E
+#define GL_PACK_COMPRESSED_BLOCK_WIDTH 0x912B
#define GL_PACK_IMAGE_HEIGHT 0x806C
#define GL_PACK_LSB_FIRST 0x0D01
#define GL_PACK_ROW_LENGTH 0x0D02
@@ -573,6 +897,11 @@ typedef void (*GLADpostcallback)(void *ret, const char *name, GLADapiproc apipro
#define GL_PACK_SKIP_PIXELS 0x0D04
#define GL_PACK_SKIP_ROWS 0x0D03
#define GL_PACK_SWAP_BYTES 0x0D00
+#define GL_PATCHES 0x000E
+#define GL_PATCH_DEFAULT_INNER_LEVEL 0x8E73
+#define GL_PATCH_DEFAULT_OUTER_LEVEL 0x8E74
+#define GL_PATCH_VERTICES 0x8E72
+#define GL_PIXEL_BUFFER_BARRIER_BIT 0x00000080
#define GL_PIXEL_PACK_BUFFER 0x88EB
#define GL_PIXEL_PACK_BUFFER_BINDING 0x88ED
#define GL_PIXEL_UNPACK_BUFFER 0x88EC
@@ -594,8 +923,18 @@ typedef void (*GLADpostcallback)(void *ret, const char *name, GLADapiproc apipro
#define GL_POLYGON_SMOOTH_HINT 0x0C53
#define GL_PRIMITIVES_GENERATED 0x8C87
#define GL_PRIMITIVE_RESTART 0x8F9D
+#define GL_PRIMITIVE_RESTART_FIXED_INDEX 0x8D69
#define GL_PRIMITIVE_RESTART_INDEX 0x8F9E
+#define GL_PROGRAM 0x82E2
+#define GL_PROGRAM_BINARY_FORMATS 0x87FF
+#define GL_PROGRAM_BINARY_LENGTH 0x8741
+#define GL_PROGRAM_BINARY_RETRIEVABLE_HINT 0x8257
+#define GL_PROGRAM_INPUT 0x92E3
+#define GL_PROGRAM_OUTPUT 0x92E4
+#define GL_PROGRAM_PIPELINE 0x82E4
+#define GL_PROGRAM_PIPELINE_BINDING 0x825A
#define GL_PROGRAM_POINT_SIZE 0x8642
+#define GL_PROGRAM_SEPARABLE 0x8258
#define GL_PROVOKING_VERTEX 0x8E4F
#define GL_PROXY_TEXTURE_1D 0x8063
#define GL_PROXY_TEXTURE_1D_ARRAY 0x8C19
@@ -605,8 +944,11 @@ typedef void (*GLADpostcallback)(void *ret, const char *name, GLADapiproc apipro
#define GL_PROXY_TEXTURE_2D_MULTISAMPLE_ARRAY 0x9103
#define GL_PROXY_TEXTURE_3D 0x8070
#define GL_PROXY_TEXTURE_CUBE_MAP 0x851B
+#define GL_PROXY_TEXTURE_CUBE_MAP_ARRAY 0x900B
#define GL_PROXY_TEXTURE_RECTANGLE 0x84F7
+#define GL_QUADS 0x0007
#define GL_QUADS_FOLLOW_PROVOKING_VERTEX_CONVENTION 0x8E4C
+#define GL_QUERY 0x82E3
#define GL_QUERY_BY_REGION_NO_WAIT 0x8E16
#define GL_QUERY_BY_REGION_WAIT 0x8E15
#define GL_QUERY_COUNTER_BITS 0x8864
@@ -633,9 +975,18 @@ typedef void (*GLADpostcallback)(void *ret, const char *name, GLADapiproc apipro
#define GL_READ_FRAMEBUFFER 0x8CA8
#define GL_READ_FRAMEBUFFER_BINDING 0x8CAA
#define GL_READ_ONLY 0x88B8
+#define GL_READ_PIXELS 0x828C
+#define GL_READ_PIXELS_FORMAT 0x828D
+#define GL_READ_PIXELS_TYPE 0x828E
#define GL_READ_WRITE 0x88BA
#define GL_RED 0x1903
#define GL_RED_INTEGER 0x8D94
+#define GL_REFERENCED_BY_COMPUTE_SHADER 0x930B
+#define GL_REFERENCED_BY_FRAGMENT_SHADER 0x930A
+#define GL_REFERENCED_BY_GEOMETRY_SHADER 0x9309
+#define GL_REFERENCED_BY_TESS_CONTROL_SHADER 0x9307
+#define GL_REFERENCED_BY_TESS_EVALUATION_SHADER 0x9308
+#define GL_REFERENCED_BY_VERTEX_SHADER 0x9306
#define GL_RENDERBUFFER 0x8D41
#define GL_RENDERBUFFER_ALPHA_SIZE 0x8D53
#define GL_RENDERBUFFER_BINDING 0x8CA7
@@ -679,6 +1030,7 @@ typedef void (*GLADpostcallback)(void *ret, const char *name, GLADapiproc apipro
#define GL_RGB32UI 0x8D71
#define GL_RGB4 0x804F
#define GL_RGB5 0x8050
+#define GL_RGB565 0x8D62
#define GL_RGB5_A1 0x8057
#define GL_RGB8 0x8051
#define GL_RGB8I 0x8D8F
@@ -705,6 +1057,7 @@ typedef void (*GLADpostcallback)(void *ret, const char *name, GLADapiproc apipro
#define GL_RGB_INTEGER 0x8D98
#define GL_RG_INTEGER 0x8228
#define GL_RIGHT 0x0407
+#define GL_SAMPLER 0x82E6
#define GL_SAMPLER_1D 0x8B5D
#define GL_SAMPLER_1D_ARRAY 0x8DC0
#define GL_SAMPLER_1D_ARRAY_SHADOW 0x8DC3
@@ -721,6 +1074,8 @@ typedef void (*GLADpostcallback)(void *ret, const char *name, GLADapiproc apipro
#define GL_SAMPLER_BINDING 0x8919
#define GL_SAMPLER_BUFFER 0x8DC2
#define GL_SAMPLER_CUBE 0x8B60
+#define GL_SAMPLER_CUBE_MAP_ARRAY 0x900C
+#define GL_SAMPLER_CUBE_MAP_ARRAY_SHADOW 0x900D
#define GL_SAMPLER_CUBE_SHADOW 0x8DC5
#define GL_SAMPLES 0x80A9
#define GL_SAMPLES_PASSED 0x8914
@@ -733,16 +1088,35 @@ typedef void (*GLADpostcallback)(void *ret, const char *name, GLADapiproc apipro
#define GL_SAMPLE_MASK 0x8E51
#define GL_SAMPLE_MASK_VALUE 0x8E52
#define GL_SAMPLE_POSITION 0x8E50
+#define GL_SAMPLE_SHADING 0x8C36
#define GL_SCISSOR_BOX 0x0C10
#define GL_SCISSOR_TEST 0x0C11
#define GL_SEPARATE_ATTRIBS 0x8C8D
#define GL_SET 0x150F
+#define GL_SHADER 0x82E1
+#define GL_SHADER_BINARY_FORMATS 0x8DF8
+#define GL_SHADER_COMPILER 0x8DFA
+#define GL_SHADER_IMAGE_ACCESS_BARRIER_BIT 0x00000020
+#define GL_SHADER_IMAGE_ATOMIC 0x82A6
+#define GL_SHADER_IMAGE_LOAD 0x82A4
+#define GL_SHADER_IMAGE_STORE 0x82A5
#define GL_SHADER_SOURCE_LENGTH 0x8B88
+#define GL_SHADER_STORAGE_BARRIER_BIT 0x00002000
+#define GL_SHADER_STORAGE_BLOCK 0x92E6
+#define GL_SHADER_STORAGE_BUFFER 0x90D2
+#define GL_SHADER_STORAGE_BUFFER_BINDING 0x90D3
+#define GL_SHADER_STORAGE_BUFFER_OFFSET_ALIGNMENT 0x90DF
+#define GL_SHADER_STORAGE_BUFFER_SIZE 0x90D5
+#define GL_SHADER_STORAGE_BUFFER_START 0x90D4
#define GL_SHADER_TYPE 0x8B4F
#define GL_SHADING_LANGUAGE_VERSION 0x8B8C
#define GL_SHORT 0x1402
#define GL_SIGNALED 0x9119
#define GL_SIGNED_NORMALIZED 0x8F9C
+#define GL_SIMULTANEOUS_TEXTURE_AND_DEPTH_TEST 0x82AC
+#define GL_SIMULTANEOUS_TEXTURE_AND_DEPTH_WRITE 0x82AE
+#define GL_SIMULTANEOUS_TEXTURE_AND_STENCIL_TEST 0x82AD
+#define GL_SIMULTANEOUS_TEXTURE_AND_STENCIL_WRITE 0x82AF
#define GL_SMOOTH_LINE_WIDTH_GRANULARITY 0x0B23
#define GL_SMOOTH_LINE_WIDTH_RANGE 0x0B22
#define GL_SMOOTH_POINT_SIZE_GRANULARITY 0x0B13
@@ -756,6 +1130,10 @@ typedef void (*GLADpostcallback)(void *ret, const char *name, GLADapiproc apipro
#define GL_SRGB8 0x8C41
#define GL_SRGB8_ALPHA8 0x8C43
#define GL_SRGB_ALPHA 0x8C42
+#define GL_SRGB_READ 0x8297
+#define GL_SRGB_WRITE 0x8298
+#define GL_STACK_OVERFLOW 0x0503
+#define GL_STACK_UNDERFLOW 0x0504
#define GL_STATIC_COPY 0x88E6
#define GL_STATIC_DRAW 0x88E4
#define GL_STATIC_READ 0x88E5
@@ -770,6 +1148,7 @@ typedef void (*GLADpostcallback)(void *ret, const char *name, GLADapiproc apipro
#define GL_STENCIL_BACK_WRITEMASK 0x8CA5
#define GL_STENCIL_BUFFER_BIT 0x00000400
#define GL_STENCIL_CLEAR_VALUE 0x0B91
+#define GL_STENCIL_COMPONENTS 0x8285
#define GL_STENCIL_FAIL 0x0B94
#define GL_STENCIL_FUNC 0x0B92
#define GL_STENCIL_INDEX 0x1901
@@ -780,6 +1159,7 @@ typedef void (*GLADpostcallback)(void *ret, const char *name, GLADapiproc apipro
#define GL_STENCIL_PASS_DEPTH_FAIL 0x0B95
#define GL_STENCIL_PASS_DEPTH_PASS 0x0B96
#define GL_STENCIL_REF 0x0B97
+#define GL_STENCIL_RENDERABLE 0x8288
#define GL_STENCIL_TEST 0x0B90
#define GL_STENCIL_VALUE_MASK 0x0B93
#define GL_STENCIL_WRITEMASK 0x0B98
@@ -794,6 +1174,21 @@ typedef void (*GLADpostcallback)(void *ret, const char *name, GLADapiproc apipro
#define GL_SYNC_FLUSH_COMMANDS_BIT 0x00000001
#define GL_SYNC_GPU_COMMANDS_COMPLETE 0x9117
#define GL_SYNC_STATUS 0x9114
+#define GL_TESS_CONTROL_OUTPUT_VERTICES 0x8E75
+#define GL_TESS_CONTROL_SHADER 0x8E88
+#define GL_TESS_CONTROL_SHADER_BIT 0x00000008
+#define GL_TESS_CONTROL_SUBROUTINE 0x92E9
+#define GL_TESS_CONTROL_SUBROUTINE_UNIFORM 0x92EF
+#define GL_TESS_CONTROL_TEXTURE 0x829C
+#define GL_TESS_EVALUATION_SHADER 0x8E87
+#define GL_TESS_EVALUATION_SHADER_BIT 0x00000010
+#define GL_TESS_EVALUATION_SUBROUTINE 0x92EA
+#define GL_TESS_EVALUATION_SUBROUTINE_UNIFORM 0x92F0
+#define GL_TESS_EVALUATION_TEXTURE 0x829D
+#define GL_TESS_GEN_MODE 0x8E76
+#define GL_TESS_GEN_POINT_MODE 0x8E79
+#define GL_TESS_GEN_SPACING 0x8E77
+#define GL_TESS_GEN_VERTEX_ORDER 0x8E78
#define GL_TEXTURE 0x1702
#define GL_TEXTURE0 0x84C0
#define GL_TEXTURE1 0x84C1
@@ -846,18 +1241,26 @@ typedef void (*GLADpostcallback)(void *ret, const char *name, GLADapiproc apipro
#define GL_TEXTURE_BINDING_3D 0x806A
#define GL_TEXTURE_BINDING_BUFFER 0x8C2C
#define GL_TEXTURE_BINDING_CUBE_MAP 0x8514
+#define GL_TEXTURE_BINDING_CUBE_MAP_ARRAY 0x900A
#define GL_TEXTURE_BINDING_RECTANGLE 0x84F6
#define GL_TEXTURE_BLUE_SIZE 0x805E
#define GL_TEXTURE_BLUE_TYPE 0x8C12
#define GL_TEXTURE_BORDER_COLOR 0x1004
#define GL_TEXTURE_BUFFER 0x8C2A
#define GL_TEXTURE_BUFFER_DATA_STORE_BINDING 0x8C2D
+#define GL_TEXTURE_BUFFER_OFFSET 0x919D
+#define GL_TEXTURE_BUFFER_OFFSET_ALIGNMENT 0x919F
+#define GL_TEXTURE_BUFFER_SIZE 0x919E
#define GL_TEXTURE_COMPARE_FUNC 0x884D
#define GL_TEXTURE_COMPARE_MODE 0x884C
#define GL_TEXTURE_COMPRESSED 0x86A1
+#define GL_TEXTURE_COMPRESSED_BLOCK_HEIGHT 0x82B2
+#define GL_TEXTURE_COMPRESSED_BLOCK_SIZE 0x82B3
+#define GL_TEXTURE_COMPRESSED_BLOCK_WIDTH 0x82B1
#define GL_TEXTURE_COMPRESSED_IMAGE_SIZE 0x86A0
#define GL_TEXTURE_COMPRESSION_HINT 0x84EF
#define GL_TEXTURE_CUBE_MAP 0x8513
+#define GL_TEXTURE_CUBE_MAP_ARRAY 0x9009
#define GL_TEXTURE_CUBE_MAP_NEGATIVE_X 0x8516
#define GL_TEXTURE_CUBE_MAP_NEGATIVE_Y 0x8518
#define GL_TEXTURE_CUBE_MAP_NEGATIVE_Z 0x851A
@@ -868,10 +1271,17 @@ typedef void (*GLADpostcallback)(void *ret, const char *name, GLADapiproc apipro
#define GL_TEXTURE_DEPTH 0x8071
#define GL_TEXTURE_DEPTH_SIZE 0x884A
#define GL_TEXTURE_DEPTH_TYPE 0x8C16
+#define GL_TEXTURE_FETCH_BARRIER_BIT 0x00000008
#define GL_TEXTURE_FIXED_SAMPLE_LOCATIONS 0x9107
+#define GL_TEXTURE_GATHER 0x82A2
+#define GL_TEXTURE_GATHER_SHADOW 0x82A3
#define GL_TEXTURE_GREEN_SIZE 0x805D
#define GL_TEXTURE_GREEN_TYPE 0x8C11
#define GL_TEXTURE_HEIGHT 0x1001
+#define GL_TEXTURE_IMAGE_FORMAT 0x828F
+#define GL_TEXTURE_IMAGE_TYPE 0x8290
+#define GL_TEXTURE_IMMUTABLE_FORMAT 0x912F
+#define GL_TEXTURE_IMMUTABLE_LEVELS 0x82DF
#define GL_TEXTURE_INTERNAL_FORMAT 0x1003
#define GL_TEXTURE_LOD_BIAS 0x8501
#define GL_TEXTURE_MAG_FILTER 0x2800
@@ -883,6 +1293,7 @@ typedef void (*GLADpostcallback)(void *ret, const char *name, GLADapiproc apipro
#define GL_TEXTURE_RED_SIZE 0x805C
#define GL_TEXTURE_RED_TYPE 0x8C10
#define GL_TEXTURE_SAMPLES 0x9106
+#define GL_TEXTURE_SHADOW 0x82A1
#define GL_TEXTURE_SHARED_SIZE 0x8C3F
#define GL_TEXTURE_STENCIL_SIZE 0x88F1
#define GL_TEXTURE_SWIZZLE_A 0x8E45
@@ -890,6 +1301,12 @@ typedef void (*GLADpostcallback)(void *ret, const char *name, GLADapiproc apipro
#define GL_TEXTURE_SWIZZLE_G 0x8E43
#define GL_TEXTURE_SWIZZLE_R 0x8E42
#define GL_TEXTURE_SWIZZLE_RGBA 0x8E46
+#define GL_TEXTURE_UPDATE_BARRIER_BIT 0x00000100
+#define GL_TEXTURE_VIEW 0x82B5
+#define GL_TEXTURE_VIEW_MIN_LAYER 0x82DD
+#define GL_TEXTURE_VIEW_MIN_LEVEL 0x82DB
+#define GL_TEXTURE_VIEW_NUM_LAYERS 0x82DE
+#define GL_TEXTURE_VIEW_NUM_LEVELS 0x82DC
#define GL_TEXTURE_WIDTH 0x1000
#define GL_TEXTURE_WRAP_R 0x8072
#define GL_TEXTURE_WRAP_S 0x2802
@@ -898,12 +1315,22 @@ typedef void (*GLADpostcallback)(void *ret, const char *name, GLADapiproc apipro
#define GL_TIMEOUT_IGNORED 0xFFFFFFFFFFFFFFFF
#define GL_TIMESTAMP 0x8E28
#define GL_TIME_ELAPSED 0x88BF
+#define GL_TOP_LEVEL_ARRAY_SIZE 0x930C
+#define GL_TOP_LEVEL_ARRAY_STRIDE 0x930D
+#define GL_TRANSFORM_FEEDBACK 0x8E22
+#define GL_TRANSFORM_FEEDBACK_ACTIVE 0x8E24
+#define GL_TRANSFORM_FEEDBACK_BARRIER_BIT 0x00000800
+#define GL_TRANSFORM_FEEDBACK_BINDING 0x8E25
#define GL_TRANSFORM_FEEDBACK_BUFFER 0x8C8E
+#define GL_TRANSFORM_FEEDBACK_BUFFER_ACTIVE 0x8E24
#define GL_TRANSFORM_FEEDBACK_BUFFER_BINDING 0x8C8F
#define GL_TRANSFORM_FEEDBACK_BUFFER_MODE 0x8C7F
+#define GL_TRANSFORM_FEEDBACK_BUFFER_PAUSED 0x8E23
#define GL_TRANSFORM_FEEDBACK_BUFFER_SIZE 0x8C85
#define GL_TRANSFORM_FEEDBACK_BUFFER_START 0x8C84
+#define GL_TRANSFORM_FEEDBACK_PAUSED 0x8E23
#define GL_TRANSFORM_FEEDBACK_PRIMITIVES_WRITTEN 0x8C88
+#define GL_TRANSFORM_FEEDBACK_VARYING 0x92F4
#define GL_TRANSFORM_FEEDBACK_VARYINGS 0x8C83
#define GL_TRANSFORM_FEEDBACK_VARYING_MAX_LENGTH 0x8C76
#define GL_TRIANGLES 0x0004
@@ -912,15 +1339,24 @@ typedef void (*GLADpostcallback)(void *ret, const char *name, GLADapiproc apipro
#define GL_TRIANGLE_STRIP 0x0005
#define GL_TRIANGLE_STRIP_ADJACENCY 0x000D
#define GL_TRUE 1
+#define GL_TYPE 0x92FA
+#define GL_UNDEFINED_VERTEX 0x8260
+#define GL_UNIFORM 0x92E1
#define GL_UNIFORM_ARRAY_STRIDE 0x8A3C
+#define GL_UNIFORM_ATOMIC_COUNTER_BUFFER_INDEX 0x92DA
+#define GL_UNIFORM_BARRIER_BIT 0x00000004
+#define GL_UNIFORM_BLOCK 0x92E2
#define GL_UNIFORM_BLOCK_ACTIVE_UNIFORMS 0x8A42
#define GL_UNIFORM_BLOCK_ACTIVE_UNIFORM_INDICES 0x8A43
#define GL_UNIFORM_BLOCK_BINDING 0x8A3F
#define GL_UNIFORM_BLOCK_DATA_SIZE 0x8A40
#define GL_UNIFORM_BLOCK_INDEX 0x8A3A
#define GL_UNIFORM_BLOCK_NAME_LENGTH 0x8A41
+#define GL_UNIFORM_BLOCK_REFERENCED_BY_COMPUTE_SHADER 0x90EC
#define GL_UNIFORM_BLOCK_REFERENCED_BY_FRAGMENT_SHADER 0x8A46
#define GL_UNIFORM_BLOCK_REFERENCED_BY_GEOMETRY_SHADER 0x8A45
+#define GL_UNIFORM_BLOCK_REFERENCED_BY_TESS_CONTROL_SHADER 0x84F0
+#define GL_UNIFORM_BLOCK_REFERENCED_BY_TESS_EVALUATION_SHADER 0x84F1
#define GL_UNIFORM_BLOCK_REFERENCED_BY_VERTEX_SHADER 0x8A44
#define GL_UNIFORM_BUFFER 0x8A11
#define GL_UNIFORM_BUFFER_BINDING 0x8A28
@@ -934,6 +1370,10 @@ typedef void (*GLADpostcallback)(void *ret, const char *name, GLADapiproc apipro
#define GL_UNIFORM_SIZE 0x8A38
#define GL_UNIFORM_TYPE 0x8A37
#define GL_UNPACK_ALIGNMENT 0x0CF5
+#define GL_UNPACK_COMPRESSED_BLOCK_DEPTH 0x9129
+#define GL_UNPACK_COMPRESSED_BLOCK_HEIGHT 0x9128
+#define GL_UNPACK_COMPRESSED_BLOCK_SIZE 0x912A
+#define GL_UNPACK_COMPRESSED_BLOCK_WIDTH 0x9127
#define GL_UNPACK_IMAGE_HEIGHT 0x806E
#define GL_UNPACK_LSB_FIRST 0x0CF1
#define GL_UNPACK_ROW_LENGTH 0x0CF2
@@ -953,6 +1393,18 @@ typedef void (*GLADpostcallback)(void *ret, const char *name, GLADapiproc apipro
#define GL_UNSIGNED_INT_5_9_9_9_REV 0x8C3E
#define GL_UNSIGNED_INT_8_8_8_8 0x8035
#define GL_UNSIGNED_INT_8_8_8_8_REV 0x8367
+#define GL_UNSIGNED_INT_ATOMIC_COUNTER 0x92DB
+#define GL_UNSIGNED_INT_IMAGE_1D 0x9062
+#define GL_UNSIGNED_INT_IMAGE_1D_ARRAY 0x9068
+#define GL_UNSIGNED_INT_IMAGE_2D 0x9063
+#define GL_UNSIGNED_INT_IMAGE_2D_ARRAY 0x9069
+#define GL_UNSIGNED_INT_IMAGE_2D_MULTISAMPLE 0x906B
+#define GL_UNSIGNED_INT_IMAGE_2D_MULTISAMPLE_ARRAY 0x906C
+#define GL_UNSIGNED_INT_IMAGE_2D_RECT 0x9065
+#define GL_UNSIGNED_INT_IMAGE_3D 0x9064
+#define GL_UNSIGNED_INT_IMAGE_BUFFER 0x9067
+#define GL_UNSIGNED_INT_IMAGE_CUBE 0x9066
+#define GL_UNSIGNED_INT_IMAGE_CUBE_MAP_ARRAY 0x906A
#define GL_UNSIGNED_INT_SAMPLER_1D 0x8DD1
#define GL_UNSIGNED_INT_SAMPLER_1D_ARRAY 0x8DD6
#define GL_UNSIGNED_INT_SAMPLER_2D 0x8DD2
@@ -963,6 +1415,7 @@ typedef void (*GLADpostcallback)(void *ret, const char *name, GLADapiproc apipro
#define GL_UNSIGNED_INT_SAMPLER_3D 0x8DD3
#define GL_UNSIGNED_INT_SAMPLER_BUFFER 0x8DD8
#define GL_UNSIGNED_INT_SAMPLER_CUBE 0x8DD4
+#define GL_UNSIGNED_INT_SAMPLER_CUBE_MAP_ARRAY 0x900F
#define GL_UNSIGNED_INT_VEC2 0x8DC6
#define GL_UNSIGNED_INT_VEC3 0x8DC7
#define GL_UNSIGNED_INT_VEC4 0x8DC8
@@ -978,19 +1431,52 @@ typedef void (*GLADpostcallback)(void *ret, const char *name, GLADapiproc apipro
#define GL_VALIDATE_STATUS 0x8B83
#define GL_VENDOR 0x1F00
#define GL_VERSION 0x1F02
+#define GL_VERTEX_ARRAY 0x8074
#define GL_VERTEX_ARRAY_BINDING 0x85B5
+#define GL_VERTEX_ATTRIB_ARRAY_BARRIER_BIT 0x00000001
#define GL_VERTEX_ATTRIB_ARRAY_BUFFER_BINDING 0x889F
#define GL_VERTEX_ATTRIB_ARRAY_DIVISOR 0x88FE
#define GL_VERTEX_ATTRIB_ARRAY_ENABLED 0x8622
#define GL_VERTEX_ATTRIB_ARRAY_INTEGER 0x88FD
+#define GL_VERTEX_ATTRIB_ARRAY_LONG 0x874E
#define GL_VERTEX_ATTRIB_ARRAY_NORMALIZED 0x886A
#define GL_VERTEX_ATTRIB_ARRAY_POINTER 0x8645
#define GL_VERTEX_ATTRIB_ARRAY_SIZE 0x8623
#define GL_VERTEX_ATTRIB_ARRAY_STRIDE 0x8624
#define GL_VERTEX_ATTRIB_ARRAY_TYPE 0x8625
+#define GL_VERTEX_ATTRIB_BINDING 0x82D4
+#define GL_VERTEX_ATTRIB_RELATIVE_OFFSET 0x82D5
+#define GL_VERTEX_BINDING_BUFFER 0x8F4F
+#define GL_VERTEX_BINDING_DIVISOR 0x82D6
+#define GL_VERTEX_BINDING_OFFSET 0x82D7
+#define GL_VERTEX_BINDING_STRIDE 0x82D8
#define GL_VERTEX_PROGRAM_POINT_SIZE 0x8642
#define GL_VERTEX_SHADER 0x8B31
+#define GL_VERTEX_SHADER_BIT 0x00000001
+#define GL_VERTEX_SUBROUTINE 0x92E8
+#define GL_VERTEX_SUBROUTINE_UNIFORM 0x92EE
+#define GL_VERTEX_TEXTURE 0x829B
#define GL_VIEWPORT 0x0BA2
+#define GL_VIEWPORT_BOUNDS_RANGE 0x825D
+#define GL_VIEWPORT_INDEX_PROVOKING_VERTEX 0x825F
+#define GL_VIEWPORT_SUBPIXEL_BITS 0x825C
+#define GL_VIEW_CLASS_128_BITS 0x82C4
+#define GL_VIEW_CLASS_16_BITS 0x82CA
+#define GL_VIEW_CLASS_24_BITS 0x82C9
+#define GL_VIEW_CLASS_32_BITS 0x82C8
+#define GL_VIEW_CLASS_48_BITS 0x82C7
+#define GL_VIEW_CLASS_64_BITS 0x82C6
+#define GL_VIEW_CLASS_8_BITS 0x82CB
+#define GL_VIEW_CLASS_96_BITS 0x82C5
+#define GL_VIEW_CLASS_BPTC_FLOAT 0x82D3
+#define GL_VIEW_CLASS_BPTC_UNORM 0x82D2
+#define GL_VIEW_CLASS_RGTC1_RED 0x82D0
+#define GL_VIEW_CLASS_RGTC2_RG 0x82D1
+#define GL_VIEW_CLASS_S3TC_DXT1_RGB 0x82CC
+#define GL_VIEW_CLASS_S3TC_DXT1_RGBA 0x82CD
+#define GL_VIEW_CLASS_S3TC_DXT3_RGBA 0x82CE
+#define GL_VIEW_CLASS_S3TC_DXT5_RGBA 0x82CF
+#define GL_VIEW_COMPATIBILITY_CLASS 0x82B6
#define GL_WAIT_FAILED 0x911D
#define GL_WRITE_ONLY 0x88B9
#define GL_XOR 0x1506
@@ -1074,12 +1560,18 @@ typedef void (GLAD_API_PTR *GLVULKANPROCNV)(void);
#define GL_VERSION_3_1 1
#define GL_VERSION_3_2 1
#define GL_VERSION_3_3 1
+#define GL_VERSION_4_0 1
+#define GL_VERSION_4_1 1
+#define GL_VERSION_4_2 1
+#define GL_VERSION_4_3 1
+typedef void (GLAD_API_PTR *PFNGLACTIVESHADERPROGRAMPROC)(GLuint pipeline, GLuint program);
typedef void (GLAD_API_PTR *PFNGLACTIVETEXTUREPROC)(GLenum texture);
typedef void (GLAD_API_PTR *PFNGLATTACHSHADERPROC)(GLuint program, GLuint shader);
typedef void (GLAD_API_PTR *PFNGLBEGINCONDITIONALRENDERPROC)(GLuint id, GLenum mode);
typedef void (GLAD_API_PTR *PFNGLBEGINQUERYPROC)(GLenum target, GLuint id);
+typedef void (GLAD_API_PTR *PFNGLBEGINQUERYINDEXEDPROC)(GLenum target, GLuint index, GLuint id);
typedef void (GLAD_API_PTR *PFNGLBEGINTRANSFORMFEEDBACKPROC)(GLenum primitiveMode);
typedef void (GLAD_API_PTR *PFNGLBINDATTRIBLOCATIONPROC)(GLuint program, GLuint index, const GLchar * name);
typedef void (GLAD_API_PTR *PFNGLBINDBUFFERPROC)(GLenum target, GLuint buffer);
@@ -1088,27 +1580,38 @@ typedef void (GLAD_API_PTR *PFNGLBINDBUFFERRANGEPROC)(GLenum target, GLuint inde
typedef void (GLAD_API_PTR *PFNGLBINDFRAGDATALOCATIONPROC)(GLuint program, GLuint color, const GLchar * name);
typedef void (GLAD_API_PTR *PFNGLBINDFRAGDATALOCATIONINDEXEDPROC)(GLuint program, GLuint colorNumber, GLuint index, const GLchar * name);
typedef void (GLAD_API_PTR *PFNGLBINDFRAMEBUFFERPROC)(GLenum target, GLuint framebuffer);
+typedef void (GLAD_API_PTR *PFNGLBINDIMAGETEXTUREPROC)(GLuint unit, GLuint texture, GLint level, GLboolean layered, GLint layer, GLenum access, GLenum format);
+typedef void (GLAD_API_PTR *PFNGLBINDPROGRAMPIPELINEPROC)(GLuint pipeline);
typedef void (GLAD_API_PTR *PFNGLBINDRENDERBUFFERPROC)(GLenum target, GLuint renderbuffer);
typedef void (GLAD_API_PTR *PFNGLBINDSAMPLERPROC)(GLuint unit, GLuint sampler);
typedef void (GLAD_API_PTR *PFNGLBINDTEXTUREPROC)(GLenum target, GLuint texture);
+typedef void (GLAD_API_PTR *PFNGLBINDTRANSFORMFEEDBACKPROC)(GLenum target, GLuint id);
typedef void (GLAD_API_PTR *PFNGLBINDVERTEXARRAYPROC)(GLuint array);
+typedef void (GLAD_API_PTR *PFNGLBINDVERTEXBUFFERPROC)(GLuint bindingindex, GLuint buffer, GLintptr offset, GLsizei stride);
typedef void (GLAD_API_PTR *PFNGLBLENDCOLORPROC)(GLfloat red, GLfloat green, GLfloat blue, GLfloat alpha);
typedef void (GLAD_API_PTR *PFNGLBLENDEQUATIONPROC)(GLenum mode);
typedef void (GLAD_API_PTR *PFNGLBLENDEQUATIONSEPARATEPROC)(GLenum modeRGB, GLenum modeAlpha);
+typedef void (GLAD_API_PTR *PFNGLBLENDEQUATIONSEPARATEIPROC)(GLuint buf, GLenum modeRGB, GLenum modeAlpha);
+typedef void (GLAD_API_PTR *PFNGLBLENDEQUATIONIPROC)(GLuint buf, GLenum mode);
typedef void (GLAD_API_PTR *PFNGLBLENDFUNCPROC)(GLenum sfactor, GLenum dfactor);
typedef void (GLAD_API_PTR *PFNGLBLENDFUNCSEPARATEPROC)(GLenum sfactorRGB, GLenum dfactorRGB, GLenum sfactorAlpha, GLenum dfactorAlpha);
+typedef void (GLAD_API_PTR *PFNGLBLENDFUNCSEPARATEIPROC)(GLuint buf, GLenum srcRGB, GLenum dstRGB, GLenum srcAlpha, GLenum dstAlpha);
+typedef void (GLAD_API_PTR *PFNGLBLENDFUNCIPROC)(GLuint buf, GLenum src, GLenum dst);
typedef void (GLAD_API_PTR *PFNGLBLITFRAMEBUFFERPROC)(GLint srcX0, GLint srcY0, GLint srcX1, GLint srcY1, GLint dstX0, GLint dstY0, GLint dstX1, GLint dstY1, GLbitfield mask, GLenum filter);
typedef void (GLAD_API_PTR *PFNGLBUFFERDATAPROC)(GLenum target, GLsizeiptr size, const void * data, GLenum usage);
typedef void (GLAD_API_PTR *PFNGLBUFFERSUBDATAPROC)(GLenum target, GLintptr offset, GLsizeiptr size, const void * data);
typedef GLenum (GLAD_API_PTR *PFNGLCHECKFRAMEBUFFERSTATUSPROC)(GLenum target);
typedef void (GLAD_API_PTR *PFNGLCLAMPCOLORPROC)(GLenum target, GLenum clamp);
typedef void (GLAD_API_PTR *PFNGLCLEARPROC)(GLbitfield mask);
+typedef void (GLAD_API_PTR *PFNGLCLEARBUFFERDATAPROC)(GLenum target, GLenum internalformat, GLenum format, GLenum type, const void * data);
+typedef void (GLAD_API_PTR *PFNGLCLEARBUFFERSUBDATAPROC)(GLenum target, GLenum internalformat, GLintptr offset, GLsizeiptr size, GLenum format, GLenum type, const void * data);
typedef void (GLAD_API_PTR *PFNGLCLEARBUFFERFIPROC)(GLenum buffer, GLint drawbuffer, GLfloat depth, GLint stencil);
typedef void (GLAD_API_PTR *PFNGLCLEARBUFFERFVPROC)(GLenum buffer, GLint drawbuffer, const GLfloat * value);
typedef void (GLAD_API_PTR *PFNGLCLEARBUFFERIVPROC)(GLenum buffer, GLint drawbuffer, const GLint * value);
typedef void (GLAD_API_PTR *PFNGLCLEARBUFFERUIVPROC)(GLenum buffer, GLint drawbuffer, const GLuint * value);
typedef void (GLAD_API_PTR *PFNGLCLEARCOLORPROC)(GLfloat red, GLfloat green, GLfloat blue, GLfloat alpha);
typedef void (GLAD_API_PTR *PFNGLCLEARDEPTHPROC)(GLdouble depth);
+typedef void (GLAD_API_PTR *PFNGLCLEARDEPTHFPROC)(GLfloat d);
typedef void (GLAD_API_PTR *PFNGLCLEARSTENCILPROC)(GLint s);
typedef GLenum (GLAD_API_PTR *PFNGLCLIENTWAITSYNCPROC)(GLsync sync, GLbitfield flags, GLuint64 timeout);
typedef void (GLAD_API_PTR *PFNGLCOLORMASKPROC)(GLboolean red, GLboolean green, GLboolean blue, GLboolean alpha);
@@ -1121,6 +1624,7 @@ typedef void (GLAD_API_PTR *PFNGLCOMPRESSEDTEXSUBIMAGE1DPROC)(GLenum target, GLi
typedef void (GLAD_API_PTR *PFNGLCOMPRESSEDTEXSUBIMAGE2DPROC)(GLenum target, GLint level, GLint xoffset, GLint yoffset, GLsizei width, GLsizei height, GLenum format, GLsizei imageSize, const void * data);
typedef void (GLAD_API_PTR *PFNGLCOMPRESSEDTEXSUBIMAGE3DPROC)(GLenum target, GLint level, GLint xoffset, GLint yoffset, GLint zoffset, GLsizei width, GLsizei height, GLsizei depth, GLenum format, GLsizei imageSize, const void * data);
typedef void (GLAD_API_PTR *PFNGLCOPYBUFFERSUBDATAPROC)(GLenum readTarget, GLenum writeTarget, GLintptr readOffset, GLintptr writeOffset, GLsizeiptr size);
+typedef void (GLAD_API_PTR *PFNGLCOPYIMAGESUBDATAPROC)(GLuint srcName, GLenum srcTarget, GLint srcLevel, GLint srcX, GLint srcY, GLint srcZ, GLuint dstName, GLenum dstTarget, GLint dstLevel, GLint dstX, GLint dstY, GLint dstZ, GLsizei srcWidth, GLsizei srcHeight, GLsizei srcDepth);
typedef void (GLAD_API_PTR *PFNGLCOPYTEXIMAGE1DPROC)(GLenum target, GLint level, GLenum internalformat, GLint x, GLint y, GLsizei width, GLint border);
typedef void (GLAD_API_PTR *PFNGLCOPYTEXIMAGE2DPROC)(GLenum target, GLint level, GLenum internalformat, GLint x, GLint y, GLsizei width, GLsizei height, GLint border);
typedef void (GLAD_API_PTR *PFNGLCOPYTEXSUBIMAGE1DPROC)(GLenum target, GLint level, GLint xoffset, GLint x, GLint y, GLsizei width);
@@ -1128,44 +1632,66 @@ typedef void (GLAD_API_PTR *PFNGLCOPYTEXSUBIMAGE2DPROC)(GLenum target, GLint lev
typedef void (GLAD_API_PTR *PFNGLCOPYTEXSUBIMAGE3DPROC)(GLenum target, GLint level, GLint xoffset, GLint yoffset, GLint zoffset, GLint x, GLint y, GLsizei width, GLsizei height);
typedef GLuint (GLAD_API_PTR *PFNGLCREATEPROGRAMPROC)(void);
typedef GLuint (GLAD_API_PTR *PFNGLCREATESHADERPROC)(GLenum type);
+typedef GLuint (GLAD_API_PTR *PFNGLCREATESHADERPROGRAMVPROC)(GLenum type, GLsizei count, const GLchar *const* strings);
typedef void (GLAD_API_PTR *PFNGLCULLFACEPROC)(GLenum mode);
+typedef void (GLAD_API_PTR *PFNGLDEBUGMESSAGECALLBACKPROC)(GLDEBUGPROC callback, const void * userParam);
+typedef void (GLAD_API_PTR *PFNGLDEBUGMESSAGECONTROLPROC)(GLenum source, GLenum type, GLenum severity, GLsizei count, const GLuint * ids, GLboolean enabled);
+typedef void (GLAD_API_PTR *PFNGLDEBUGMESSAGEINSERTPROC)(GLenum source, GLenum type, GLuint id, GLenum severity, GLsizei length, const GLchar * buf);
typedef void (GLAD_API_PTR *PFNGLDELETEBUFFERSPROC)(GLsizei n, const GLuint * buffers);
typedef void (GLAD_API_PTR *PFNGLDELETEFRAMEBUFFERSPROC)(GLsizei n, const GLuint * framebuffers);
typedef void (GLAD_API_PTR *PFNGLDELETEPROGRAMPROC)(GLuint program);
+typedef void (GLAD_API_PTR *PFNGLDELETEPROGRAMPIPELINESPROC)(GLsizei n, const GLuint * pipelines);
typedef void (GLAD_API_PTR *PFNGLDELETEQUERIESPROC)(GLsizei n, const GLuint * ids);
typedef void (GLAD_API_PTR *PFNGLDELETERENDERBUFFERSPROC)(GLsizei n, const GLuint * renderbuffers);
typedef void (GLAD_API_PTR *PFNGLDELETESAMPLERSPROC)(GLsizei count, const GLuint * samplers);
typedef void (GLAD_API_PTR *PFNGLDELETESHADERPROC)(GLuint shader);
typedef void (GLAD_API_PTR *PFNGLDELETESYNCPROC)(GLsync sync);
typedef void (GLAD_API_PTR *PFNGLDELETETEXTURESPROC)(GLsizei n, const GLuint * textures);
+typedef void (GLAD_API_PTR *PFNGLDELETETRANSFORMFEEDBACKSPROC)(GLsizei n, const GLuint * ids);
typedef void (GLAD_API_PTR *PFNGLDELETEVERTEXARRAYSPROC)(GLsizei n, const GLuint * arrays);
typedef void (GLAD_API_PTR *PFNGLDEPTHFUNCPROC)(GLenum func);
typedef void (GLAD_API_PTR *PFNGLDEPTHMASKPROC)(GLboolean flag);
typedef void (GLAD_API_PTR *PFNGLDEPTHRANGEPROC)(GLdouble n, GLdouble f);
+typedef void (GLAD_API_PTR *PFNGLDEPTHRANGEARRAYVPROC)(GLuint first, GLsizei count, const GLdouble * v);
+typedef void (GLAD_API_PTR *PFNGLDEPTHRANGEINDEXEDPROC)(GLuint index, GLdouble n, GLdouble f);
+typedef void (GLAD_API_PTR *PFNGLDEPTHRANGEFPROC)(GLfloat n, GLfloat f);
typedef void (GLAD_API_PTR *PFNGLDETACHSHADERPROC)(GLuint program, GLuint shader);
typedef void (GLAD_API_PTR *PFNGLDISABLEPROC)(GLenum cap);
typedef void (GLAD_API_PTR *PFNGLDISABLEVERTEXATTRIBARRAYPROC)(GLuint index);
typedef void (GLAD_API_PTR *PFNGLDISABLEIPROC)(GLenum target, GLuint index);
+typedef void (GLAD_API_PTR *PFNGLDISPATCHCOMPUTEPROC)(GLuint num_groups_x, GLuint num_groups_y, GLuint num_groups_z);
+typedef void (GLAD_API_PTR *PFNGLDISPATCHCOMPUTEINDIRECTPROC)(GLintptr indirect);
typedef void (GLAD_API_PTR *PFNGLDRAWARRAYSPROC)(GLenum mode, GLint first, GLsizei count);
+typedef void (GLAD_API_PTR *PFNGLDRAWARRAYSINDIRECTPROC)(GLenum mode, const void * indirect);
typedef void (GLAD_API_PTR *PFNGLDRAWARRAYSINSTANCEDPROC)(GLenum mode, GLint first, GLsizei count, GLsizei instancecount);
+typedef void (GLAD_API_PTR *PFNGLDRAWARRAYSINSTANCEDBASEINSTANCEPROC)(GLenum mode, GLint first, GLsizei count, GLsizei instancecount, GLuint baseinstance);
typedef void (GLAD_API_PTR *PFNGLDRAWBUFFERPROC)(GLenum buf);
typedef void (GLAD_API_PTR *PFNGLDRAWBUFFERSPROC)(GLsizei n, const GLenum * bufs);
typedef void (GLAD_API_PTR *PFNGLDRAWELEMENTSPROC)(GLenum mode, GLsizei count, GLenum type, const void * indices);
typedef void (GLAD_API_PTR *PFNGLDRAWELEMENTSBASEVERTEXPROC)(GLenum mode, GLsizei count, GLenum type, const void * indices, GLint basevertex);
+typedef void (GLAD_API_PTR *PFNGLDRAWELEMENTSINDIRECTPROC)(GLenum mode, GLenum type, const void * indirect);
typedef void (GLAD_API_PTR *PFNGLDRAWELEMENTSINSTANCEDPROC)(GLenum mode, GLsizei count, GLenum type, const void * indices, GLsizei instancecount);
+typedef void (GLAD_API_PTR *PFNGLDRAWELEMENTSINSTANCEDBASEINSTANCEPROC)(GLenum mode, GLsizei count, GLenum type, const void * indices, GLsizei instancecount, GLuint baseinstance);
typedef void (GLAD_API_PTR *PFNGLDRAWELEMENTSINSTANCEDBASEVERTEXPROC)(GLenum mode, GLsizei count, GLenum type, const void * indices, GLsizei instancecount, GLint basevertex);
+typedef void (GLAD_API_PTR *PFNGLDRAWELEMENTSINSTANCEDBASEVERTEXBASEINSTANCEPROC)(GLenum mode, GLsizei count, GLenum type, const void * indices, GLsizei instancecount, GLint basevertex, GLuint baseinstance);
typedef void (GLAD_API_PTR *PFNGLDRAWRANGEELEMENTSPROC)(GLenum mode, GLuint start, GLuint end, GLsizei count, GLenum type, const void * indices);
typedef void (GLAD_API_PTR *PFNGLDRAWRANGEELEMENTSBASEVERTEXPROC)(GLenum mode, GLuint start, GLuint end, GLsizei count, GLenum type, const void * indices, GLint basevertex);
+typedef void (GLAD_API_PTR *PFNGLDRAWTRANSFORMFEEDBACKPROC)(GLenum mode, GLuint id);
+typedef void (GLAD_API_PTR *PFNGLDRAWTRANSFORMFEEDBACKINSTANCEDPROC)(GLenum mode, GLuint id, GLsizei instancecount);
+typedef void (GLAD_API_PTR *PFNGLDRAWTRANSFORMFEEDBACKSTREAMPROC)(GLenum mode, GLuint id, GLuint stream);
+typedef void (GLAD_API_PTR *PFNGLDRAWTRANSFORMFEEDBACKSTREAMINSTANCEDPROC)(GLenum mode, GLuint id, GLuint stream, GLsizei instancecount);
typedef void (GLAD_API_PTR *PFNGLENABLEPROC)(GLenum cap);
typedef void (GLAD_API_PTR *PFNGLENABLEVERTEXATTRIBARRAYPROC)(GLuint index);
typedef void (GLAD_API_PTR *PFNGLENABLEIPROC)(GLenum target, GLuint index);
typedef void (GLAD_API_PTR *PFNGLENDCONDITIONALRENDERPROC)(void);
typedef void (GLAD_API_PTR *PFNGLENDQUERYPROC)(GLenum target);
+typedef void (GLAD_API_PTR *PFNGLENDQUERYINDEXEDPROC)(GLenum target, GLuint index);
typedef void (GLAD_API_PTR *PFNGLENDTRANSFORMFEEDBACKPROC)(void);
typedef GLsync (GLAD_API_PTR *PFNGLFENCESYNCPROC)(GLenum condition, GLbitfield flags);
typedef void (GLAD_API_PTR *PFNGLFINISHPROC)(void);
typedef void (GLAD_API_PTR *PFNGLFLUSHPROC)(void);
typedef void (GLAD_API_PTR *PFNGLFLUSHMAPPEDBUFFERRANGEPROC)(GLenum target, GLintptr offset, GLsizeiptr length);
+typedef void (GLAD_API_PTR *PFNGLFRAMEBUFFERPARAMETERIPROC)(GLenum target, GLenum pname, GLint param);
typedef void (GLAD_API_PTR *PFNGLFRAMEBUFFERRENDERBUFFERPROC)(GLenum target, GLenum attachment, GLenum renderbuffertarget, GLuint renderbuffer);
typedef void (GLAD_API_PTR *PFNGLFRAMEBUFFERTEXTUREPROC)(GLenum target, GLenum attachment, GLuint texture, GLint level);
typedef void (GLAD_API_PTR *PFNGLFRAMEBUFFERTEXTURE1DPROC)(GLenum target, GLenum attachment, GLenum textarget, GLuint texture, GLint level);
@@ -1175,13 +1701,19 @@ typedef void (GLAD_API_PTR *PFNGLFRAMEBUFFERTEXTURELAYERPROC)(GLenum target, GLe
typedef void (GLAD_API_PTR *PFNGLFRONTFACEPROC)(GLenum mode);
typedef void (GLAD_API_PTR *PFNGLGENBUFFERSPROC)(GLsizei n, GLuint * buffers);
typedef void (GLAD_API_PTR *PFNGLGENFRAMEBUFFERSPROC)(GLsizei n, GLuint * framebuffers);
+typedef void (GLAD_API_PTR *PFNGLGENPROGRAMPIPELINESPROC)(GLsizei n, GLuint * pipelines);
typedef void (GLAD_API_PTR *PFNGLGENQUERIESPROC)(GLsizei n, GLuint * ids);
typedef void (GLAD_API_PTR *PFNGLGENRENDERBUFFERSPROC)(GLsizei n, GLuint * renderbuffers);
typedef void (GLAD_API_PTR *PFNGLGENSAMPLERSPROC)(GLsizei count, GLuint * samplers);
typedef void (GLAD_API_PTR *PFNGLGENTEXTURESPROC)(GLsizei n, GLuint * textures);
+typedef void (GLAD_API_PTR *PFNGLGENTRANSFORMFEEDBACKSPROC)(GLsizei n, GLuint * ids);
typedef void (GLAD_API_PTR *PFNGLGENVERTEXARRAYSPROC)(GLsizei n, GLuint * arrays);
typedef void (GLAD_API_PTR *PFNGLGENERATEMIPMAPPROC)(GLenum target);
+typedef void (GLAD_API_PTR *PFNGLGETACTIVEATOMICCOUNTERBUFFERIVPROC)(GLuint program, GLuint bufferIndex, GLenum pname, GLint * params);
typedef void (GLAD_API_PTR *PFNGLGETACTIVEATTRIBPROC)(GLuint program, GLuint index, GLsizei bufSize, GLsizei * length, GLint * size, GLenum * type, GLchar * name);
+typedef void (GLAD_API_PTR *PFNGLGETACTIVESUBROUTINENAMEPROC)(GLuint program, GLenum shadertype, GLuint index, GLsizei bufSize, GLsizei * length, GLchar * name);
+typedef void (GLAD_API_PTR *PFNGLGETACTIVESUBROUTINEUNIFORMNAMEPROC)(GLuint program, GLenum shadertype, GLuint index, GLsizei bufSize, GLsizei * length, GLchar * name);
+typedef void (GLAD_API_PTR *PFNGLGETACTIVESUBROUTINEUNIFORMIVPROC)(GLuint program, GLenum shadertype, GLuint index, GLenum pname, GLint * values);
typedef void (GLAD_API_PTR *PFNGLGETACTIVEUNIFORMPROC)(GLuint program, GLuint index, GLsizei bufSize, GLsizei * length, GLint * size, GLenum * type, GLchar * name);
typedef void (GLAD_API_PTR *PFNGLGETACTIVEUNIFORMBLOCKNAMEPROC)(GLuint program, GLuint uniformBlockIndex, GLsizei bufSize, GLsizei * length, GLchar * uniformBlockName);
typedef void (GLAD_API_PTR *PFNGLGETACTIVEUNIFORMBLOCKIVPROC)(GLuint program, GLuint uniformBlockIndex, GLenum pname, GLint * params);
@@ -1196,19 +1728,39 @@ typedef void (GLAD_API_PTR *PFNGLGETBUFFERPARAMETERIVPROC)(GLenum target, GLenum
typedef void (GLAD_API_PTR *PFNGLGETBUFFERPOINTERVPROC)(GLenum target, GLenum pname, void ** params);
typedef void (GLAD_API_PTR *PFNGLGETBUFFERSUBDATAPROC)(GLenum target, GLintptr offset, GLsizeiptr size, void * data);
typedef void (GLAD_API_PTR *PFNGLGETCOMPRESSEDTEXIMAGEPROC)(GLenum target, GLint level, void * img);
+typedef GLuint (GLAD_API_PTR *PFNGLGETDEBUGMESSAGELOGPROC)(GLuint count, GLsizei bufSize, GLenum * sources, GLenum * types, GLuint * ids, GLenum * severities, GLsizei * lengths, GLchar * messageLog);
+typedef void (GLAD_API_PTR *PFNGLGETDOUBLEI_VPROC)(GLenum target, GLuint index, GLdouble * data);
typedef void (GLAD_API_PTR *PFNGLGETDOUBLEVPROC)(GLenum pname, GLdouble * data);
typedef GLenum (GLAD_API_PTR *PFNGLGETERRORPROC)(void);
+typedef void (GLAD_API_PTR *PFNGLGETFLOATI_VPROC)(GLenum target, GLuint index, GLfloat * data);
typedef void (GLAD_API_PTR *PFNGLGETFLOATVPROC)(GLenum pname, GLfloat * data);
typedef GLint (GLAD_API_PTR *PFNGLGETFRAGDATAINDEXPROC)(GLuint program, const GLchar * name);
typedef GLint (GLAD_API_PTR *PFNGLGETFRAGDATALOCATIONPROC)(GLuint program, const GLchar * name);
typedef void (GLAD_API_PTR *PFNGLGETFRAMEBUFFERATTACHMENTPARAMETERIVPROC)(GLenum target, GLenum attachment, GLenum pname, GLint * params);
+typedef void (GLAD_API_PTR *PFNGLGETFRAMEBUFFERPARAMETERIVPROC)(GLenum target, GLenum pname, GLint * params);
typedef void (GLAD_API_PTR *PFNGLGETINTEGER64I_VPROC)(GLenum target, GLuint index, GLint64 * data);
typedef void (GLAD_API_PTR *PFNGLGETINTEGER64VPROC)(GLenum pname, GLint64 * data);
typedef void (GLAD_API_PTR *PFNGLGETINTEGERI_VPROC)(GLenum target, GLuint index, GLint * data);
typedef void (GLAD_API_PTR *PFNGLGETINTEGERVPROC)(GLenum pname, GLint * data);
+typedef void (GLAD_API_PTR *PFNGLGETINTERNALFORMATI64VPROC)(GLenum target, GLenum internalformat, GLenum pname, GLsizei count, GLint64 * params);
+typedef void (GLAD_API_PTR *PFNGLGETINTERNALFORMATIVPROC)(GLenum target, GLenum internalformat, GLenum pname, GLsizei count, GLint * params);
typedef void (GLAD_API_PTR *PFNGLGETMULTISAMPLEFVPROC)(GLenum pname, GLuint index, GLfloat * val);
+typedef void (GLAD_API_PTR *PFNGLGETOBJECTLABELPROC)(GLenum identifier, GLuint name, GLsizei bufSize, GLsizei * length, GLchar * label);
+typedef void (GLAD_API_PTR *PFNGLGETOBJECTPTRLABELPROC)(const void * ptr, GLsizei bufSize, GLsizei * length, GLchar * label);
+typedef void (GLAD_API_PTR *PFNGLGETPOINTERVPROC)(GLenum pname, void ** params);
+typedef void (GLAD_API_PTR *PFNGLGETPROGRAMBINARYPROC)(GLuint program, GLsizei bufSize, GLsizei * length, GLenum * binaryFormat, void * binary);
typedef void (GLAD_API_PTR *PFNGLGETPROGRAMINFOLOGPROC)(GLuint program, GLsizei bufSize, GLsizei * length, GLchar * infoLog);
+typedef void (GLAD_API_PTR *PFNGLGETPROGRAMINTERFACEIVPROC)(GLuint program, GLenum programInterface, GLenum pname, GLint * params);
+typedef void (GLAD_API_PTR *PFNGLGETPROGRAMPIPELINEINFOLOGPROC)(GLuint pipeline, GLsizei bufSize, GLsizei * length, GLchar * infoLog);
+typedef void (GLAD_API_PTR *PFNGLGETPROGRAMPIPELINEIVPROC)(GLuint pipeline, GLenum pname, GLint * params);
+typedef GLuint (GLAD_API_PTR *PFNGLGETPROGRAMRESOURCEINDEXPROC)(GLuint program, GLenum programInterface, const GLchar * name);
+typedef GLint (GLAD_API_PTR *PFNGLGETPROGRAMRESOURCELOCATIONPROC)(GLuint program, GLenum programInterface, const GLchar * name);
+typedef GLint (GLAD_API_PTR *PFNGLGETPROGRAMRESOURCELOCATIONINDEXPROC)(GLuint program, GLenum programInterface, const GLchar * name);
+typedef void (GLAD_API_PTR *PFNGLGETPROGRAMRESOURCENAMEPROC)(GLuint program, GLenum programInterface, GLuint index, GLsizei bufSize, GLsizei * length, GLchar * name);
+typedef void (GLAD_API_PTR *PFNGLGETPROGRAMRESOURCEIVPROC)(GLuint program, GLenum programInterface, GLuint index, GLsizei propCount, const GLenum * props, GLsizei count, GLsizei * length, GLint * params);
+typedef void (GLAD_API_PTR *PFNGLGETPROGRAMSTAGEIVPROC)(GLuint program, GLenum shadertype, GLenum pname, GLint * values);
typedef void (GLAD_API_PTR *PFNGLGETPROGRAMIVPROC)(GLuint program, GLenum pname, GLint * params);
+typedef void (GLAD_API_PTR *PFNGLGETQUERYINDEXEDIVPROC)(GLenum target, GLuint index, GLenum pname, GLint * params);
typedef void (GLAD_API_PTR *PFNGLGETQUERYOBJECTI64VPROC)(GLuint id, GLenum pname, GLint64 * params);
typedef void (GLAD_API_PTR *PFNGLGETQUERYOBJECTIVPROC)(GLuint id, GLenum pname, GLint * params);
typedef void (GLAD_API_PTR *PFNGLGETQUERYOBJECTUI64VPROC)(GLuint id, GLenum pname, GLuint64 * params);
@@ -1220,10 +1772,13 @@ typedef void (GLAD_API_PTR *PFNGLGETSAMPLERPARAMETERIUIVPROC)(GLuint sampler, GL
typedef void (GLAD_API_PTR *PFNGLGETSAMPLERPARAMETERFVPROC)(GLuint sampler, GLenum pname, GLfloat * params);
typedef void (GLAD_API_PTR *PFNGLGETSAMPLERPARAMETERIVPROC)(GLuint sampler, GLenum pname, GLint * params);
typedef void (GLAD_API_PTR *PFNGLGETSHADERINFOLOGPROC)(GLuint shader, GLsizei bufSize, GLsizei * length, GLchar * infoLog);
+typedef void (GLAD_API_PTR *PFNGLGETSHADERPRECISIONFORMATPROC)(GLenum shadertype, GLenum precisiontype, GLint * range, GLint * precision);
typedef void (GLAD_API_PTR *PFNGLGETSHADERSOURCEPROC)(GLuint shader, GLsizei bufSize, GLsizei * length, GLchar * source);
typedef void (GLAD_API_PTR *PFNGLGETSHADERIVPROC)(GLuint shader, GLenum pname, GLint * params);
typedef const GLubyte * (GLAD_API_PTR *PFNGLGETSTRINGPROC)(GLenum name);
typedef const GLubyte * (GLAD_API_PTR *PFNGLGETSTRINGIPROC)(GLenum name, GLuint index);
+typedef GLuint (GLAD_API_PTR *PFNGLGETSUBROUTINEINDEXPROC)(GLuint program, GLenum shadertype, const GLchar * name);
+typedef GLint (GLAD_API_PTR *PFNGLGETSUBROUTINEUNIFORMLOCATIONPROC)(GLuint program, GLenum shadertype, const GLchar * name);
typedef void (GLAD_API_PTR *PFNGLGETSYNCIVPROC)(GLsync sync, GLenum pname, GLsizei count, GLsizei * length, GLint * values);
typedef void (GLAD_API_PTR *PFNGLGETTEXIMAGEPROC)(GLenum target, GLint level, GLenum format, GLenum type, void * pixels);
typedef void (GLAD_API_PTR *PFNGLGETTEXLEVELPARAMETERFVPROC)(GLenum target, GLint level, GLenum pname, GLfloat * params);
@@ -1236,36 +1791,56 @@ typedef void (GLAD_API_PTR *PFNGLGETTRANSFORMFEEDBACKVARYINGPROC)(GLuint program
typedef GLuint (GLAD_API_PTR *PFNGLGETUNIFORMBLOCKINDEXPROC)(GLuint program, const GLchar * uniformBlockName);
typedef void (GLAD_API_PTR *PFNGLGETUNIFORMINDICESPROC)(GLuint program, GLsizei uniformCount, const GLchar *const* uniformNames, GLuint * uniformIndices);
typedef GLint (GLAD_API_PTR *PFNGLGETUNIFORMLOCATIONPROC)(GLuint program, const GLchar * name);
+typedef void (GLAD_API_PTR *PFNGLGETUNIFORMSUBROUTINEUIVPROC)(GLenum shadertype, GLint location, GLuint * params);
+typedef void (GLAD_API_PTR *PFNGLGETUNIFORMDVPROC)(GLuint program, GLint location, GLdouble * params);
typedef void (GLAD_API_PTR *PFNGLGETUNIFORMFVPROC)(GLuint program, GLint location, GLfloat * params);
typedef void (GLAD_API_PTR *PFNGLGETUNIFORMIVPROC)(GLuint program, GLint location, GLint * params);
typedef void (GLAD_API_PTR *PFNGLGETUNIFORMUIVPROC)(GLuint program, GLint location, GLuint * params);
typedef void (GLAD_API_PTR *PFNGLGETVERTEXATTRIBIIVPROC)(GLuint index, GLenum pname, GLint * params);
typedef void (GLAD_API_PTR *PFNGLGETVERTEXATTRIBIUIVPROC)(GLuint index, GLenum pname, GLuint * params);
+typedef void (GLAD_API_PTR *PFNGLGETVERTEXATTRIBLDVPROC)(GLuint index, GLenum pname, GLdouble * params);
typedef void (GLAD_API_PTR *PFNGLGETVERTEXATTRIBPOINTERVPROC)(GLuint index, GLenum pname, void ** pointer);
typedef void (GLAD_API_PTR *PFNGLGETVERTEXATTRIBDVPROC)(GLuint index, GLenum pname, GLdouble * params);
typedef void (GLAD_API_PTR *PFNGLGETVERTEXATTRIBFVPROC)(GLuint index, GLenum pname, GLfloat * params);
typedef void (GLAD_API_PTR *PFNGLGETVERTEXATTRIBIVPROC)(GLuint index, GLenum pname, GLint * params);
typedef void (GLAD_API_PTR *PFNGLHINTPROC)(GLenum target, GLenum mode);
+typedef void (GLAD_API_PTR *PFNGLINVALIDATEBUFFERDATAPROC)(GLuint buffer);
+typedef void (GLAD_API_PTR *PFNGLINVALIDATEBUFFERSUBDATAPROC)(GLuint buffer, GLintptr offset, GLsizeiptr length);
+typedef void (GLAD_API_PTR *PFNGLINVALIDATEFRAMEBUFFERPROC)(GLenum target, GLsizei numAttachments, const GLenum * attachments);
+typedef void (GLAD_API_PTR *PFNGLINVALIDATESUBFRAMEBUFFERPROC)(GLenum target, GLsizei numAttachments, const GLenum * attachments, GLint x, GLint y, GLsizei width, GLsizei height);
+typedef void (GLAD_API_PTR *PFNGLINVALIDATETEXIMAGEPROC)(GLuint texture, GLint level);
+typedef void (GLAD_API_PTR *PFNGLINVALIDATETEXSUBIMAGEPROC)(GLuint texture, GLint level, GLint xoffset, GLint yoffset, GLint zoffset, GLsizei width, GLsizei height, GLsizei depth);
typedef GLboolean (GLAD_API_PTR *PFNGLISBUFFERPROC)(GLuint buffer);
typedef GLboolean (GLAD_API_PTR *PFNGLISENABLEDPROC)(GLenum cap);
typedef GLboolean (GLAD_API_PTR *PFNGLISENABLEDIPROC)(GLenum target, GLuint index);
typedef GLboolean (GLAD_API_PTR *PFNGLISFRAMEBUFFERPROC)(GLuint framebuffer);
typedef GLboolean (GLAD_API_PTR *PFNGLISPROGRAMPROC)(GLuint program);
+typedef GLboolean (GLAD_API_PTR *PFNGLISPROGRAMPIPELINEPROC)(GLuint pipeline);
typedef GLboolean (GLAD_API_PTR *PFNGLISQUERYPROC)(GLuint id);
typedef GLboolean (GLAD_API_PTR *PFNGLISRENDERBUFFERPROC)(GLuint renderbuffer);
typedef GLboolean (GLAD_API_PTR *PFNGLISSAMPLERPROC)(GLuint sampler);
typedef GLboolean (GLAD_API_PTR *PFNGLISSHADERPROC)(GLuint shader);
typedef GLboolean (GLAD_API_PTR *PFNGLISSYNCPROC)(GLsync sync);
typedef GLboolean (GLAD_API_PTR *PFNGLISTEXTUREPROC)(GLuint texture);
+typedef GLboolean (GLAD_API_PTR *PFNGLISTRANSFORMFEEDBACKPROC)(GLuint id);
typedef GLboolean (GLAD_API_PTR *PFNGLISVERTEXARRAYPROC)(GLuint array);
typedef void (GLAD_API_PTR *PFNGLLINEWIDTHPROC)(GLfloat width);
typedef void (GLAD_API_PTR *PFNGLLINKPROGRAMPROC)(GLuint program);
typedef void (GLAD_API_PTR *PFNGLLOGICOPPROC)(GLenum opcode);
typedef void * (GLAD_API_PTR *PFNGLMAPBUFFERPROC)(GLenum target, GLenum access);
typedef void * (GLAD_API_PTR *PFNGLMAPBUFFERRANGEPROC)(GLenum target, GLintptr offset, GLsizeiptr length, GLbitfield access);
+typedef void (GLAD_API_PTR *PFNGLMEMORYBARRIERPROC)(GLbitfield barriers);
+typedef void (GLAD_API_PTR *PFNGLMINSAMPLESHADINGPROC)(GLfloat value);
typedef void (GLAD_API_PTR *PFNGLMULTIDRAWARRAYSPROC)(GLenum mode, const GLint * first, const GLsizei * count, GLsizei drawcount);
+typedef void (GLAD_API_PTR *PFNGLMULTIDRAWARRAYSINDIRECTPROC)(GLenum mode, const void * indirect, GLsizei drawcount, GLsizei stride);
typedef void (GLAD_API_PTR *PFNGLMULTIDRAWELEMENTSPROC)(GLenum mode, const GLsizei * count, GLenum type, const void *const* indices, GLsizei drawcount);
typedef void (GLAD_API_PTR *PFNGLMULTIDRAWELEMENTSBASEVERTEXPROC)(GLenum mode, const GLsizei * count, GLenum type, const void *const* indices, GLsizei drawcount, const GLint * basevertex);
+typedef void (GLAD_API_PTR *PFNGLMULTIDRAWELEMENTSINDIRECTPROC)(GLenum mode, GLenum type, const void * indirect, GLsizei drawcount, GLsizei stride);
+typedef void (GLAD_API_PTR *PFNGLOBJECTLABELPROC)(GLenum identifier, GLuint name, GLsizei length, const GLchar * label);
+typedef void (GLAD_API_PTR *PFNGLOBJECTPTRLABELPROC)(const void * ptr, GLsizei length, const GLchar * label);
+typedef void (GLAD_API_PTR *PFNGLPATCHPARAMETERFVPROC)(GLenum pname, const GLfloat * values);
+typedef void (GLAD_API_PTR *PFNGLPATCHPARAMETERIPROC)(GLenum pname, GLint value);
+typedef void (GLAD_API_PTR *PFNGLPAUSETRANSFORMFEEDBACKPROC)(void);
typedef void (GLAD_API_PTR *PFNGLPIXELSTOREFPROC)(GLenum pname, GLfloat param);
typedef void (GLAD_API_PTR *PFNGLPIXELSTOREIPROC)(GLenum pname, GLint param);
typedef void (GLAD_API_PTR *PFNGLPOINTPARAMETERFPROC)(GLenum pname, GLfloat param);
@@ -1275,13 +1850,69 @@ typedef void (GLAD_API_PTR *PFNGLPOINTPARAMETERIVPROC)(GLenum pname, const GLint
typedef void (GLAD_API_PTR *PFNGLPOINTSIZEPROC)(GLfloat size);
typedef void (GLAD_API_PTR *PFNGLPOLYGONMODEPROC)(GLenum face, GLenum mode);
typedef void (GLAD_API_PTR *PFNGLPOLYGONOFFSETPROC)(GLfloat factor, GLfloat units);
+typedef void (GLAD_API_PTR *PFNGLPOPDEBUGGROUPPROC)(void);
typedef void (GLAD_API_PTR *PFNGLPRIMITIVERESTARTINDEXPROC)(GLuint index);
+typedef void (GLAD_API_PTR *PFNGLPROGRAMBINARYPROC)(GLuint program, GLenum binaryFormat, const void * binary, GLsizei length);
+typedef void (GLAD_API_PTR *PFNGLPROGRAMPARAMETERIPROC)(GLuint program, GLenum pname, GLint value);
+typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORM1DPROC)(GLuint program, GLint location, GLdouble v0);
+typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORM1DVPROC)(GLuint program, GLint location, GLsizei count, const GLdouble * value);
+typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORM1FPROC)(GLuint program, GLint location, GLfloat v0);
+typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORM1FVPROC)(GLuint program, GLint location, GLsizei count, const GLfloat * value);
+typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORM1IPROC)(GLuint program, GLint location, GLint v0);
+typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORM1IVPROC)(GLuint program, GLint location, GLsizei count, const GLint * value);
+typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORM1UIPROC)(GLuint program, GLint location, GLuint v0);
+typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORM1UIVPROC)(GLuint program, GLint location, GLsizei count, const GLuint * value);
+typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORM2DPROC)(GLuint program, GLint location, GLdouble v0, GLdouble v1);
+typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORM2DVPROC)(GLuint program, GLint location, GLsizei count, const GLdouble * value);
+typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORM2FPROC)(GLuint program, GLint location, GLfloat v0, GLfloat v1);
+typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORM2FVPROC)(GLuint program, GLint location, GLsizei count, const GLfloat * value);
+typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORM2IPROC)(GLuint program, GLint location, GLint v0, GLint v1);
+typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORM2IVPROC)(GLuint program, GLint location, GLsizei count, const GLint * value);
+typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORM2UIPROC)(GLuint program, GLint location, GLuint v0, GLuint v1);
+typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORM2UIVPROC)(GLuint program, GLint location, GLsizei count, const GLuint * value);
+typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORM3DPROC)(GLuint program, GLint location, GLdouble v0, GLdouble v1, GLdouble v2);
+typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORM3DVPROC)(GLuint program, GLint location, GLsizei count, const GLdouble * value);
+typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORM3FPROC)(GLuint program, GLint location, GLfloat v0, GLfloat v1, GLfloat v2);
+typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORM3FVPROC)(GLuint program, GLint location, GLsizei count, const GLfloat * value);
+typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORM3IPROC)(GLuint program, GLint location, GLint v0, GLint v1, GLint v2);
+typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORM3IVPROC)(GLuint program, GLint location, GLsizei count, const GLint * value);
+typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORM3UIPROC)(GLuint program, GLint location, GLuint v0, GLuint v1, GLuint v2);
+typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORM3UIVPROC)(GLuint program, GLint location, GLsizei count, const GLuint * value);
+typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORM4DPROC)(GLuint program, GLint location, GLdouble v0, GLdouble v1, GLdouble v2, GLdouble v3);
+typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORM4DVPROC)(GLuint program, GLint location, GLsizei count, const GLdouble * value);
+typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORM4FPROC)(GLuint program, GLint location, GLfloat v0, GLfloat v1, GLfloat v2, GLfloat v3);
+typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORM4FVPROC)(GLuint program, GLint location, GLsizei count, const GLfloat * value);
+typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORM4IPROC)(GLuint program, GLint location, GLint v0, GLint v1, GLint v2, GLint v3);
+typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORM4IVPROC)(GLuint program, GLint location, GLsizei count, const GLint * value);
+typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORM4UIPROC)(GLuint program, GLint location, GLuint v0, GLuint v1, GLuint v2, GLuint v3);
+typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORM4UIVPROC)(GLuint program, GLint location, GLsizei count, const GLuint * value);
+typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORMMATRIX2DVPROC)(GLuint program, GLint location, GLsizei count, GLboolean transpose, const GLdouble * value);
+typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORMMATRIX2FVPROC)(GLuint program, GLint location, GLsizei count, GLboolean transpose, const GLfloat * value);
+typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORMMATRIX2X3DVPROC)(GLuint program, GLint location, GLsizei count, GLboolean transpose, const GLdouble * value);
+typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORMMATRIX2X3FVPROC)(GLuint program, GLint location, GLsizei count, GLboolean transpose, const GLfloat * value);
+typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORMMATRIX2X4DVPROC)(GLuint program, GLint location, GLsizei count, GLboolean transpose, const GLdouble * value);
+typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORMMATRIX2X4FVPROC)(GLuint program, GLint location, GLsizei count, GLboolean transpose, const GLfloat * value);
+typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORMMATRIX3DVPROC)(GLuint program, GLint location, GLsizei count, GLboolean transpose, const GLdouble * value);
+typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORMMATRIX3FVPROC)(GLuint program, GLint location, GLsizei count, GLboolean transpose, const GLfloat * value);
+typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORMMATRIX3X2DVPROC)(GLuint program, GLint location, GLsizei count, GLboolean transpose, const GLdouble * value);
+typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORMMATRIX3X2FVPROC)(GLuint program, GLint location, GLsizei count, GLboolean transpose, const GLfloat * value);
+typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORMMATRIX3X4DVPROC)(GLuint program, GLint location, GLsizei count, GLboolean transpose, const GLdouble * value);
+typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORMMATRIX3X4FVPROC)(GLuint program, GLint location, GLsizei count, GLboolean transpose, const GLfloat * value);
+typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORMMATRIX4DVPROC)(GLuint program, GLint location, GLsizei count, GLboolean transpose, const GLdouble * value);
+typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORMMATRIX4FVPROC)(GLuint program, GLint location, GLsizei count, GLboolean transpose, const GLfloat * value);
+typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORMMATRIX4X2DVPROC)(GLuint program, GLint location, GLsizei count, GLboolean transpose, const GLdouble * value);
+typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORMMATRIX4X2FVPROC)(GLuint program, GLint location, GLsizei count, GLboolean transpose, const GLfloat * value);
+typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORMMATRIX4X3DVPROC)(GLuint program, GLint location, GLsizei count, GLboolean transpose, const GLdouble * value);
+typedef void (GLAD_API_PTR *PFNGLPROGRAMUNIFORMMATRIX4X3FVPROC)(GLuint program, GLint location, GLsizei count, GLboolean transpose, const GLfloat * value);
typedef void (GLAD_API_PTR *PFNGLPROVOKINGVERTEXPROC)(GLenum mode);
+typedef void (GLAD_API_PTR *PFNGLPUSHDEBUGGROUPPROC)(GLenum source, GLuint id, GLsizei length, const GLchar * message);
typedef void (GLAD_API_PTR *PFNGLQUERYCOUNTERPROC)(GLuint id, GLenum target);
typedef void (GLAD_API_PTR *PFNGLREADBUFFERPROC)(GLenum src);
typedef void (GLAD_API_PTR *PFNGLREADPIXELSPROC)(GLint x, GLint y, GLsizei width, GLsizei height, GLenum format, GLenum type, void * pixels);
+typedef void (GLAD_API_PTR *PFNGLRELEASESHADERCOMPILERPROC)(void);
typedef void (GLAD_API_PTR *PFNGLRENDERBUFFERSTORAGEPROC)(GLenum target, GLenum internalformat, GLsizei width, GLsizei height);
typedef void (GLAD_API_PTR *PFNGLRENDERBUFFERSTORAGEMULTISAMPLEPROC)(GLenum target, GLsizei samples, GLenum internalformat, GLsizei width, GLsizei height);
+typedef void (GLAD_API_PTR *PFNGLRESUMETRANSFORMFEEDBACKPROC)(void);
typedef void (GLAD_API_PTR *PFNGLSAMPLECOVERAGEPROC)(GLfloat value, GLboolean invert);
typedef void (GLAD_API_PTR *PFNGLSAMPLEMASKIPROC)(GLuint maskNumber, GLbitfield mask);
typedef void (GLAD_API_PTR *PFNGLSAMPLERPARAMETERIIVPROC)(GLuint sampler, GLenum pname, const GLint * param);
@@ -1291,7 +1922,12 @@ typedef void (GLAD_API_PTR *PFNGLSAMPLERPARAMETERFVPROC)(GLuint sampler, GLenum
typedef void (GLAD_API_PTR *PFNGLSAMPLERPARAMETERIPROC)(GLuint sampler, GLenum pname, GLint param);
typedef void (GLAD_API_PTR *PFNGLSAMPLERPARAMETERIVPROC)(GLuint sampler, GLenum pname, const GLint * param);
typedef void (GLAD_API_PTR *PFNGLSCISSORPROC)(GLint x, GLint y, GLsizei width, GLsizei height);
+typedef void (GLAD_API_PTR *PFNGLSCISSORARRAYVPROC)(GLuint first, GLsizei count, const GLint * v);
+typedef void (GLAD_API_PTR *PFNGLSCISSORINDEXEDPROC)(GLuint index, GLint left, GLint bottom, GLsizei width, GLsizei height);
+typedef void (GLAD_API_PTR *PFNGLSCISSORINDEXEDVPROC)(GLuint index, const GLint * v);
+typedef void (GLAD_API_PTR *PFNGLSHADERBINARYPROC)(GLsizei count, const GLuint * shaders, GLenum binaryFormat, const void * binary, GLsizei length);
typedef void (GLAD_API_PTR *PFNGLSHADERSOURCEPROC)(GLuint shader, GLsizei count, const GLchar *const* string, const GLint * length);
+typedef void (GLAD_API_PTR *PFNGLSHADERSTORAGEBLOCKBINDINGPROC)(GLuint program, GLuint storageBlockIndex, GLuint storageBlockBinding);
typedef void (GLAD_API_PTR *PFNGLSTENCILFUNCPROC)(GLenum func, GLint ref, GLuint mask);
typedef void (GLAD_API_PTR *PFNGLSTENCILFUNCSEPARATEPROC)(GLenum face, GLenum func, GLint ref, GLuint mask);
typedef void (GLAD_API_PTR *PFNGLSTENCILMASKPROC)(GLuint mask);
@@ -1299,6 +1935,7 @@ typedef void (GLAD_API_PTR *PFNGLSTENCILMASKSEPARATEPROC)(GLenum face, GLuint ma
typedef void (GLAD_API_PTR *PFNGLSTENCILOPPROC)(GLenum fail, GLenum zfail, GLenum zpass);
typedef void (GLAD_API_PTR *PFNGLSTENCILOPSEPARATEPROC)(GLenum face, GLenum sfail, GLenum dpfail, GLenum dppass);
typedef void (GLAD_API_PTR *PFNGLTEXBUFFERPROC)(GLenum target, GLenum internalformat, GLuint buffer);
+typedef void (GLAD_API_PTR *PFNGLTEXBUFFERRANGEPROC)(GLenum target, GLenum internalformat, GLuint buffer, GLintptr offset, GLsizeiptr size);
typedef void (GLAD_API_PTR *PFNGLTEXIMAGE1DPROC)(GLenum target, GLint level, GLint internalformat, GLsizei width, GLint border, GLenum format, GLenum type, const void * pixels);
typedef void (GLAD_API_PTR *PFNGLTEXIMAGE2DPROC)(GLenum target, GLint level, GLint internalformat, GLsizei width, GLsizei height, GLint border, GLenum format, GLenum type, const void * pixels);
typedef void (GLAD_API_PTR *PFNGLTEXIMAGE2DMULTISAMPLEPROC)(GLenum target, GLsizei samples, GLenum internalformat, GLsizei width, GLsizei height, GLboolean fixedsamplelocations);
@@ -1310,28 +1947,42 @@ typedef void (GLAD_API_PTR *PFNGLTEXPARAMETERFPROC)(GLenum target, GLenum pname,
typedef void (GLAD_API_PTR *PFNGLTEXPARAMETERFVPROC)(GLenum target, GLenum pname, const GLfloat * params);
typedef void (GLAD_API_PTR *PFNGLTEXPARAMETERIPROC)(GLenum target, GLenum pname, GLint param);
typedef void (GLAD_API_PTR *PFNGLTEXPARAMETERIVPROC)(GLenum target, GLenum pname, const GLint * params);
+typedef void (GLAD_API_PTR *PFNGLTEXSTORAGE1DPROC)(GLenum target, GLsizei levels, GLenum internalformat, GLsizei width);
+typedef void (GLAD_API_PTR *PFNGLTEXSTORAGE2DPROC)(GLenum target, GLsizei levels, GLenum internalformat, GLsizei width, GLsizei height);
+typedef void (GLAD_API_PTR *PFNGLTEXSTORAGE2DMULTISAMPLEPROC)(GLenum target, GLsizei samples, GLenum internalformat, GLsizei width, GLsizei height, GLboolean fixedsamplelocations);
+typedef void (GLAD_API_PTR *PFNGLTEXSTORAGE3DPROC)(GLenum target, GLsizei levels, GLenum internalformat, GLsizei width, GLsizei height, GLsizei depth);
+typedef void (GLAD_API_PTR *PFNGLTEXSTORAGE3DMULTISAMPLEPROC)(GLenum target, GLsizei samples, GLenum internalformat, GLsizei width, GLsizei height, GLsizei depth, GLboolean fixedsamplelocations);
typedef void (GLAD_API_PTR *PFNGLTEXSUBIMAGE1DPROC)(GLenum target, GLint level, GLint xoffset, GLsizei width, GLenum format, GLenum type, const void * pixels);
typedef void (GLAD_API_PTR *PFNGLTEXSUBIMAGE2DPROC)(GLenum target, GLint level, GLint xoffset, GLint yoffset, GLsizei width, GLsizei height, GLenum format, GLenum type, const void * pixels);
typedef void (GLAD_API_PTR *PFNGLTEXSUBIMAGE3DPROC)(GLenum target, GLint level, GLint xoffset, GLint yoffset, GLint zoffset, GLsizei width, GLsizei height, GLsizei depth, GLenum format, GLenum type, const void * pixels);
+typedef void (GLAD_API_PTR *PFNGLTEXTUREVIEWPROC)(GLuint texture, GLenum target, GLuint origtexture, GLenum internalformat, GLuint minlevel, GLuint numlevels, GLuint minlayer, GLuint numlayers);
typedef void (GLAD_API_PTR *PFNGLTRANSFORMFEEDBACKVARYINGSPROC)(GLuint program, GLsizei count, const GLchar *const* varyings, GLenum bufferMode);
+typedef void (GLAD_API_PTR *PFNGLUNIFORM1DPROC)(GLint location, GLdouble x);
+typedef void (GLAD_API_PTR *PFNGLUNIFORM1DVPROC)(GLint location, GLsizei count, const GLdouble * value);
typedef void (GLAD_API_PTR *PFNGLUNIFORM1FPROC)(GLint location, GLfloat v0);
typedef void (GLAD_API_PTR *PFNGLUNIFORM1FVPROC)(GLint location, GLsizei count, const GLfloat * value);
typedef void (GLAD_API_PTR *PFNGLUNIFORM1IPROC)(GLint location, GLint v0);
typedef void (GLAD_API_PTR *PFNGLUNIFORM1IVPROC)(GLint location, GLsizei count, const GLint * value);
typedef void (GLAD_API_PTR *PFNGLUNIFORM1UIPROC)(GLint location, GLuint v0);
typedef void (GLAD_API_PTR *PFNGLUNIFORM1UIVPROC)(GLint location, GLsizei count, const GLuint * value);
+typedef void (GLAD_API_PTR *PFNGLUNIFORM2DPROC)(GLint location, GLdouble x, GLdouble y);
+typedef void (GLAD_API_PTR *PFNGLUNIFORM2DVPROC)(GLint location, GLsizei count, const GLdouble * value);
typedef void (GLAD_API_PTR *PFNGLUNIFORM2FPROC)(GLint location, GLfloat v0, GLfloat v1);
typedef void (GLAD_API_PTR *PFNGLUNIFORM2FVPROC)(GLint location, GLsizei count, const GLfloat * value);
typedef void (GLAD_API_PTR *PFNGLUNIFORM2IPROC)(GLint location, GLint v0, GLint v1);
typedef void (GLAD_API_PTR *PFNGLUNIFORM2IVPROC)(GLint location, GLsizei count, const GLint * value);
typedef void (GLAD_API_PTR *PFNGLUNIFORM2UIPROC)(GLint location, GLuint v0, GLuint v1);
typedef void (GLAD_API_PTR *PFNGLUNIFORM2UIVPROC)(GLint location, GLsizei count, const GLuint * value);
+typedef void (GLAD_API_PTR *PFNGLUNIFORM3DPROC)(GLint location, GLdouble x, GLdouble y, GLdouble z);
+typedef void (GLAD_API_PTR *PFNGLUNIFORM3DVPROC)(GLint location, GLsizei count, const GLdouble * value);
typedef void (GLAD_API_PTR *PFNGLUNIFORM3FPROC)(GLint location, GLfloat v0, GLfloat v1, GLfloat v2);
typedef void (GLAD_API_PTR *PFNGLUNIFORM3FVPROC)(GLint location, GLsizei count, const GLfloat * value);
typedef void (GLAD_API_PTR *PFNGLUNIFORM3IPROC)(GLint location, GLint v0, GLint v1, GLint v2);
typedef void (GLAD_API_PTR *PFNGLUNIFORM3IVPROC)(GLint location, GLsizei count, const GLint * value);
typedef void (GLAD_API_PTR *PFNGLUNIFORM3UIPROC)(GLint location, GLuint v0, GLuint v1, GLuint v2);
typedef void (GLAD_API_PTR *PFNGLUNIFORM3UIVPROC)(GLint location, GLsizei count, const GLuint * value);
+typedef void (GLAD_API_PTR *PFNGLUNIFORM4DPROC)(GLint location, GLdouble x, GLdouble y, GLdouble z, GLdouble w);
+typedef void (GLAD_API_PTR *PFNGLUNIFORM4DVPROC)(GLint location, GLsizei count, const GLdouble * value);
typedef void (GLAD_API_PTR *PFNGLUNIFORM4FPROC)(GLint location, GLfloat v0, GLfloat v1, GLfloat v2, GLfloat v3);
typedef void (GLAD_API_PTR *PFNGLUNIFORM4FVPROC)(GLint location, GLsizei count, const GLfloat * value);
typedef void (GLAD_API_PTR *PFNGLUNIFORM4IPROC)(GLint location, GLint v0, GLint v1, GLint v2, GLint v3);
@@ -1339,18 +1990,30 @@ typedef void (GLAD_API_PTR *PFNGLUNIFORM4IVPROC)(GLint location, GLsizei count,
typedef void (GLAD_API_PTR *PFNGLUNIFORM4UIPROC)(GLint location, GLuint v0, GLuint v1, GLuint v2, GLuint v3);
typedef void (GLAD_API_PTR *PFNGLUNIFORM4UIVPROC)(GLint location, GLsizei count, const GLuint * value);
typedef void (GLAD_API_PTR *PFNGLUNIFORMBLOCKBINDINGPROC)(GLuint program, GLuint uniformBlockIndex, GLuint uniformBlockBinding);
+typedef void (GLAD_API_PTR *PFNGLUNIFORMMATRIX2DVPROC)(GLint location, GLsizei count, GLboolean transpose, const GLdouble * value);
typedef void (GLAD_API_PTR *PFNGLUNIFORMMATRIX2FVPROC)(GLint location, GLsizei count, GLboolean transpose, const GLfloat * value);
+typedef void (GLAD_API_PTR *PFNGLUNIFORMMATRIX2X3DVPROC)(GLint location, GLsizei count, GLboolean transpose, const GLdouble * value);
typedef void (GLAD_API_PTR *PFNGLUNIFORMMATRIX2X3FVPROC)(GLint location, GLsizei count, GLboolean transpose, const GLfloat * value);
+typedef void (GLAD_API_PTR *PFNGLUNIFORMMATRIX2X4DVPROC)(GLint location, GLsizei count, GLboolean transpose, const GLdouble * value);
typedef void (GLAD_API_PTR *PFNGLUNIFORMMATRIX2X4FVPROC)(GLint location, GLsizei count, GLboolean transpose, const GLfloat * value);
+typedef void (GLAD_API_PTR *PFNGLUNIFORMMATRIX3DVPROC)(GLint location, GLsizei count, GLboolean transpose, const GLdouble * value);
typedef void (GLAD_API_PTR *PFNGLUNIFORMMATRIX3FVPROC)(GLint location, GLsizei count, GLboolean transpose, const GLfloat * value);
+typedef void (GLAD_API_PTR *PFNGLUNIFORMMATRIX3X2DVPROC)(GLint location, GLsizei count, GLboolean transpose, const GLdouble * value);
typedef void (GLAD_API_PTR *PFNGLUNIFORMMATRIX3X2FVPROC)(GLint location, GLsizei count, GLboolean transpose, const GLfloat * value);
+typedef void (GLAD_API_PTR *PFNGLUNIFORMMATRIX3X4DVPROC)(GLint location, GLsizei count, GLboolean transpose, const GLdouble * value);
typedef void (GLAD_API_PTR *PFNGLUNIFORMMATRIX3X4FVPROC)(GLint location, GLsizei count, GLboolean transpose, const GLfloat * value);
+typedef void (GLAD_API_PTR *PFNGLUNIFORMMATRIX4DVPROC)(GLint location, GLsizei count, GLboolean transpose, const GLdouble * value);
typedef void (GLAD_API_PTR *PFNGLUNIFORMMATRIX4FVPROC)(GLint location, GLsizei count, GLboolean transpose, const GLfloat * value);
+typedef void (GLAD_API_PTR *PFNGLUNIFORMMATRIX4X2DVPROC)(GLint location, GLsizei count, GLboolean transpose, const GLdouble * value);
typedef void (GLAD_API_PTR *PFNGLUNIFORMMATRIX4X2FVPROC)(GLint location, GLsizei count, GLboolean transpose, const GLfloat * value);
+typedef void (GLAD_API_PTR *PFNGLUNIFORMMATRIX4X3DVPROC)(GLint location, GLsizei count, GLboolean transpose, const GLdouble * value);
typedef void (GLAD_API_PTR *PFNGLUNIFORMMATRIX4X3FVPROC)(GLint location, GLsizei count, GLboolean transpose, const GLfloat * value);
+typedef void (GLAD_API_PTR *PFNGLUNIFORMSUBROUTINESUIVPROC)(GLenum shadertype, GLsizei count, const GLuint * indices);
typedef GLboolean (GLAD_API_PTR *PFNGLUNMAPBUFFERPROC)(GLenum target);
typedef void (GLAD_API_PTR *PFNGLUSEPROGRAMPROC)(GLuint program);
+typedef void (GLAD_API_PTR *PFNGLUSEPROGRAMSTAGESPROC)(GLuint pipeline, GLbitfield stages, GLuint program);
typedef void (GLAD_API_PTR *PFNGLVALIDATEPROGRAMPROC)(GLuint program);
+typedef void (GLAD_API_PTR *PFNGLVALIDATEPROGRAMPIPELINEPROC)(GLuint pipeline);
typedef void (GLAD_API_PTR *PFNGLVERTEXATTRIB1DPROC)(GLuint index, GLdouble x);
typedef void (GLAD_API_PTR *PFNGLVERTEXATTRIB1DVPROC)(GLuint index, const GLdouble * v);
typedef void (GLAD_API_PTR *PFNGLVERTEXATTRIB1FPROC)(GLuint index, GLfloat x);
@@ -1387,7 +2050,9 @@ typedef void (GLAD_API_PTR *PFNGLVERTEXATTRIB4SVPROC)(GLuint index, const GLshor
typedef void (GLAD_API_PTR *PFNGLVERTEXATTRIB4UBVPROC)(GLuint index, const GLubyte * v);
typedef void (GLAD_API_PTR *PFNGLVERTEXATTRIB4UIVPROC)(GLuint index, const GLuint * v);
typedef void (GLAD_API_PTR *PFNGLVERTEXATTRIB4USVPROC)(GLuint index, const GLushort * v);
+typedef void (GLAD_API_PTR *PFNGLVERTEXATTRIBBINDINGPROC)(GLuint attribindex, GLuint bindingindex);
typedef void (GLAD_API_PTR *PFNGLVERTEXATTRIBDIVISORPROC)(GLuint index, GLuint divisor);
+typedef void (GLAD_API_PTR *PFNGLVERTEXATTRIBFORMATPROC)(GLuint attribindex, GLint size, GLenum type, GLboolean normalized, GLuint relativeoffset);
typedef void (GLAD_API_PTR *PFNGLVERTEXATTRIBI1IPROC)(GLuint index, GLint x);
typedef void (GLAD_API_PTR *PFNGLVERTEXATTRIBI1IVPROC)(GLuint index, const GLint * v);
typedef void (GLAD_API_PTR *PFNGLVERTEXATTRIBI1UIPROC)(GLuint index, GLuint x);
@@ -1408,7 +2073,18 @@ typedef void (GLAD_API_PTR *PFNGLVERTEXATTRIBI4UBVPROC)(GLuint index, const GLub
typedef void (GLAD_API_PTR *PFNGLVERTEXATTRIBI4UIPROC)(GLuint index, GLuint x, GLuint y, GLuint z, GLuint w);
typedef void (GLAD_API_PTR *PFNGLVERTEXATTRIBI4UIVPROC)(GLuint index, const GLuint * v);
typedef void (GLAD_API_PTR *PFNGLVERTEXATTRIBI4USVPROC)(GLuint index, const GLushort * v);
+typedef void (GLAD_API_PTR *PFNGLVERTEXATTRIBIFORMATPROC)(GLuint attribindex, GLint size, GLenum type, GLuint relativeoffset);
typedef void (GLAD_API_PTR *PFNGLVERTEXATTRIBIPOINTERPROC)(GLuint index, GLint size, GLenum type, GLsizei stride, const void * pointer);
+typedef void (GLAD_API_PTR *PFNGLVERTEXATTRIBL1DPROC)(GLuint index, GLdouble x);
+typedef void (GLAD_API_PTR *PFNGLVERTEXATTRIBL1DVPROC)(GLuint index, const GLdouble * v);
+typedef void (GLAD_API_PTR *PFNGLVERTEXATTRIBL2DPROC)(GLuint index, GLdouble x, GLdouble y);
+typedef void (GLAD_API_PTR *PFNGLVERTEXATTRIBL2DVPROC)(GLuint index, const GLdouble * v);
+typedef void (GLAD_API_PTR *PFNGLVERTEXATTRIBL3DPROC)(GLuint index, GLdouble x, GLdouble y, GLdouble z);
+typedef void (GLAD_API_PTR *PFNGLVERTEXATTRIBL3DVPROC)(GLuint index, const GLdouble * v);
+typedef void (GLAD_API_PTR *PFNGLVERTEXATTRIBL4DPROC)(GLuint index, GLdouble x, GLdouble y, GLdouble z, GLdouble w);
+typedef void (GLAD_API_PTR *PFNGLVERTEXATTRIBL4DVPROC)(GLuint index, const GLdouble * v);
+typedef void (GLAD_API_PTR *PFNGLVERTEXATTRIBLFORMATPROC)(GLuint attribindex, GLint size, GLenum type, GLuint relativeoffset);
+typedef void (GLAD_API_PTR *PFNGLVERTEXATTRIBLPOINTERPROC)(GLuint index, GLint size, GLenum type, GLsizei stride, const void * pointer);
typedef void (GLAD_API_PTR *PFNGLVERTEXATTRIBP1UIPROC)(GLuint index, GLenum type, GLboolean normalized, GLuint value);
typedef void (GLAD_API_PTR *PFNGLVERTEXATTRIBP1UIVPROC)(GLuint index, GLenum type, GLboolean normalized, const GLuint * value);
typedef void (GLAD_API_PTR *PFNGLVERTEXATTRIBP2UIPROC)(GLuint index, GLenum type, GLboolean normalized, GLuint value);
@@ -1418,7 +2094,11 @@ typedef void (GLAD_API_PTR *PFNGLVERTEXATTRIBP3UIVPROC)(GLuint index, GLenum typ
typedef void (GLAD_API_PTR *PFNGLVERTEXATTRIBP4UIPROC)(GLuint index, GLenum type, GLboolean normalized, GLuint value);
typedef void (GLAD_API_PTR *PFNGLVERTEXATTRIBP4UIVPROC)(GLuint index, GLenum type, GLboolean normalized, const GLuint * value);
typedef void (GLAD_API_PTR *PFNGLVERTEXATTRIBPOINTERPROC)(GLuint index, GLint size, GLenum type, GLboolean normalized, GLsizei stride, const void * pointer);
+typedef void (GLAD_API_PTR *PFNGLVERTEXBINDINGDIVISORPROC)(GLuint bindingindex, GLuint divisor);
typedef void (GLAD_API_PTR *PFNGLVIEWPORTPROC)(GLint x, GLint y, GLsizei width, GLsizei height);
+typedef void (GLAD_API_PTR *PFNGLVIEWPORTARRAYVPROC)(GLuint first, GLsizei count, const GLfloat * v);
+typedef void (GLAD_API_PTR *PFNGLVIEWPORTINDEXEDFPROC)(GLuint index, GLfloat x, GLfloat y, GLfloat w, GLfloat h);
+typedef void (GLAD_API_PTR *PFNGLVIEWPORTINDEXEDFVPROC)(GLuint index, const GLfloat * v);
typedef void (GLAD_API_PTR *PFNGLWAITSYNCPROC)(GLsync sync, GLbitfield flags, GLuint64 timeout);
typedef struct GladGLContext {
@@ -1436,11 +2116,17 @@ typedef struct GladGLContext {
int VERSION_3_1;
int VERSION_3_2;
int VERSION_3_3;
+ int VERSION_4_0;
+ int VERSION_4_1;
+ int VERSION_4_2;
+ int VERSION_4_3;
+ PFNGLACTIVESHADERPROGRAMPROC ActiveShaderProgram;
PFNGLACTIVETEXTUREPROC ActiveTexture;
PFNGLATTACHSHADERPROC AttachShader;
PFNGLBEGINCONDITIONALRENDERPROC BeginConditionalRender;
PFNGLBEGINQUERYPROC BeginQuery;
+ PFNGLBEGINQUERYINDEXEDPROC BeginQueryIndexed;
PFNGLBEGINTRANSFORMFEEDBACKPROC BeginTransformFeedback;
PFNGLBINDATTRIBLOCATIONPROC BindAttribLocation;
PFNGLBINDBUFFERPROC BindBuffer;
@@ -1449,27 +2135,38 @@ typedef struct GladGLContext {
PFNGLBINDFRAGDATALOCATIONPROC BindFragDataLocation;
PFNGLBINDFRAGDATALOCATIONINDEXEDPROC BindFragDataLocationIndexed;
PFNGLBINDFRAMEBUFFERPROC BindFramebuffer;
+ PFNGLBINDIMAGETEXTUREPROC BindImageTexture;
+ PFNGLBINDPROGRAMPIPELINEPROC BindProgramPipeline;
PFNGLBINDRENDERBUFFERPROC BindRenderbuffer;
PFNGLBINDSAMPLERPROC BindSampler;
PFNGLBINDTEXTUREPROC BindTexture;
+ PFNGLBINDTRANSFORMFEEDBACKPROC BindTransformFeedback;
PFNGLBINDVERTEXARRAYPROC BindVertexArray;
+ PFNGLBINDVERTEXBUFFERPROC BindVertexBuffer;
PFNGLBLENDCOLORPROC BlendColor;
PFNGLBLENDEQUATIONPROC BlendEquation;
PFNGLBLENDEQUATIONSEPARATEPROC BlendEquationSeparate;
+ PFNGLBLENDEQUATIONSEPARATEIPROC BlendEquationSeparatei;
+ PFNGLBLENDEQUATIONIPROC BlendEquationi;
PFNGLBLENDFUNCPROC BlendFunc;
PFNGLBLENDFUNCSEPARATEPROC BlendFuncSeparate;
+ PFNGLBLENDFUNCSEPARATEIPROC BlendFuncSeparatei;
+ PFNGLBLENDFUNCIPROC BlendFunci;
PFNGLBLITFRAMEBUFFERPROC BlitFramebuffer;
PFNGLBUFFERDATAPROC BufferData;
PFNGLBUFFERSUBDATAPROC BufferSubData;
PFNGLCHECKFRAMEBUFFERSTATUSPROC CheckFramebufferStatus;
PFNGLCLAMPCOLORPROC ClampColor;
PFNGLCLEARPROC Clear;
+ PFNGLCLEARBUFFERDATAPROC ClearBufferData;
+ PFNGLCLEARBUFFERSUBDATAPROC ClearBufferSubData;
PFNGLCLEARBUFFERFIPROC ClearBufferfi;
PFNGLCLEARBUFFERFVPROC ClearBufferfv;
PFNGLCLEARBUFFERIVPROC ClearBufferiv;
PFNGLCLEARBUFFERUIVPROC ClearBufferuiv;
PFNGLCLEARCOLORPROC ClearColor;
PFNGLCLEARDEPTHPROC ClearDepth;
+ PFNGLCLEARDEPTHFPROC ClearDepthf;
PFNGLCLEARSTENCILPROC ClearStencil;
PFNGLCLIENTWAITSYNCPROC ClientWaitSync;
PFNGLCOLORMASKPROC ColorMask;
@@ -1482,6 +2179,7 @@ typedef struct GladGLContext {
PFNGLCOMPRESSEDTEXSUBIMAGE2DPROC CompressedTexSubImage2D;
PFNGLCOMPRESSEDTEXSUBIMAGE3DPROC CompressedTexSubImage3D;
PFNGLCOPYBUFFERSUBDATAPROC CopyBufferSubData;
+ PFNGLCOPYIMAGESUBDATAPROC CopyImageSubData;
PFNGLCOPYTEXIMAGE1DPROC CopyTexImage1D;
PFNGLCOPYTEXIMAGE2DPROC CopyTexImage2D;
PFNGLCOPYTEXSUBIMAGE1DPROC CopyTexSubImage1D;
@@ -1489,44 +2187,66 @@ typedef struct GladGLContext {
PFNGLCOPYTEXSUBIMAGE3DPROC CopyTexSubImage3D;
PFNGLCREATEPROGRAMPROC CreateProgram;
PFNGLCREATESHADERPROC CreateShader;
+ PFNGLCREATESHADERPROGRAMVPROC CreateShaderProgramv;
PFNGLCULLFACEPROC CullFace;
+ PFNGLDEBUGMESSAGECALLBACKPROC DebugMessageCallback;
+ PFNGLDEBUGMESSAGECONTROLPROC DebugMessageControl;
+ PFNGLDEBUGMESSAGEINSERTPROC DebugMessageInsert;
PFNGLDELETEBUFFERSPROC DeleteBuffers;
PFNGLDELETEFRAMEBUFFERSPROC DeleteFramebuffers;
PFNGLDELETEPROGRAMPROC DeleteProgram;
+ PFNGLDELETEPROGRAMPIPELINESPROC DeleteProgramPipelines;
PFNGLDELETEQUERIESPROC DeleteQueries;
PFNGLDELETERENDERBUFFERSPROC DeleteRenderbuffers;
PFNGLDELETESAMPLERSPROC DeleteSamplers;
PFNGLDELETESHADERPROC DeleteShader;
PFNGLDELETESYNCPROC DeleteSync;
PFNGLDELETETEXTURESPROC DeleteTextures;
+ PFNGLDELETETRANSFORMFEEDBACKSPROC DeleteTransformFeedbacks;
PFNGLDELETEVERTEXARRAYSPROC DeleteVertexArrays;
PFNGLDEPTHFUNCPROC DepthFunc;
PFNGLDEPTHMASKPROC DepthMask;
PFNGLDEPTHRANGEPROC DepthRange;
+ PFNGLDEPTHRANGEARRAYVPROC DepthRangeArrayv;
+ PFNGLDEPTHRANGEINDEXEDPROC DepthRangeIndexed;
+ PFNGLDEPTHRANGEFPROC DepthRangef;
PFNGLDETACHSHADERPROC DetachShader;
PFNGLDISABLEPROC Disable;
PFNGLDISABLEVERTEXATTRIBARRAYPROC DisableVertexAttribArray;
PFNGLDISABLEIPROC Disablei;
+ PFNGLDISPATCHCOMPUTEPROC DispatchCompute;
+ PFNGLDISPATCHCOMPUTEINDIRECTPROC DispatchComputeIndirect;
PFNGLDRAWARRAYSPROC DrawArrays;
+ PFNGLDRAWARRAYSINDIRECTPROC DrawArraysIndirect;
PFNGLDRAWARRAYSINSTANCEDPROC DrawArraysInstanced;
+ PFNGLDRAWARRAYSINSTANCEDBASEINSTANCEPROC DrawArraysInstancedBaseInstance;
PFNGLDRAWBUFFERPROC DrawBuffer;
PFNGLDRAWBUFFERSPROC DrawBuffers;
PFNGLDRAWELEMENTSPROC DrawElements;
PFNGLDRAWELEMENTSBASEVERTEXPROC DrawElementsBaseVertex;
+ PFNGLDRAWELEMENTSINDIRECTPROC DrawElementsIndirect;
PFNGLDRAWELEMENTSINSTANCEDPROC DrawElementsInstanced;
+ PFNGLDRAWELEMENTSINSTANCEDBASEINSTANCEPROC DrawElementsInstancedBaseInstance;
PFNGLDRAWELEMENTSINSTANCEDBASEVERTEXPROC DrawElementsInstancedBaseVertex;
+ PFNGLDRAWELEMENTSINSTANCEDBASEVERTEXBASEINSTANCEPROC DrawElementsInstancedBaseVertexBaseInstance;
PFNGLDRAWRANGEELEMENTSPROC DrawRangeElements;
PFNGLDRAWRANGEELEMENTSBASEVERTEXPROC DrawRangeElementsBaseVertex;
+ PFNGLDRAWTRANSFORMFEEDBACKPROC DrawTransformFeedback;
+ PFNGLDRAWTRANSFORMFEEDBACKINSTANCEDPROC DrawTransformFeedbackInstanced;
+ PFNGLDRAWTRANSFORMFEEDBACKSTREAMPROC DrawTransformFeedbackStream;
+ PFNGLDRAWTRANSFORMFEEDBACKSTREAMINSTANCEDPROC DrawTransformFeedbackStreamInstanced;
PFNGLENABLEPROC Enable;
PFNGLENABLEVERTEXATTRIBARRAYPROC EnableVertexAttribArray;
PFNGLENABLEIPROC Enablei;
PFNGLENDCONDITIONALRENDERPROC EndConditionalRender;
PFNGLENDQUERYPROC EndQuery;
+ PFNGLENDQUERYINDEXEDPROC EndQueryIndexed;
PFNGLENDTRANSFORMFEEDBACKPROC EndTransformFeedback;
PFNGLFENCESYNCPROC FenceSync;
PFNGLFINISHPROC Finish;
PFNGLFLUSHPROC Flush;
PFNGLFLUSHMAPPEDBUFFERRANGEPROC FlushMappedBufferRange;
+ PFNGLFRAMEBUFFERPARAMETERIPROC FramebufferParameteri;
PFNGLFRAMEBUFFERRENDERBUFFERPROC FramebufferRenderbuffer;
PFNGLFRAMEBUFFERTEXTUREPROC FramebufferTexture;
PFNGLFRAMEBUFFERTEXTURE1DPROC FramebufferTexture1D;
@@ -1536,13 +2256,19 @@ typedef struct GladGLContext {
PFNGLFRONTFACEPROC FrontFace;
PFNGLGENBUFFERSPROC GenBuffers;
PFNGLGENFRAMEBUFFERSPROC GenFramebuffers;
+ PFNGLGENPROGRAMPIPELINESPROC GenProgramPipelines;
PFNGLGENQUERIESPROC GenQueries;
PFNGLGENRENDERBUFFERSPROC GenRenderbuffers;
PFNGLGENSAMPLERSPROC GenSamplers;
PFNGLGENTEXTURESPROC GenTextures;
+ PFNGLGENTRANSFORMFEEDBACKSPROC GenTransformFeedbacks;
PFNGLGENVERTEXARRAYSPROC GenVertexArrays;
PFNGLGENERATEMIPMAPPROC GenerateMipmap;
+ PFNGLGETACTIVEATOMICCOUNTERBUFFERIVPROC GetActiveAtomicCounterBufferiv;
PFNGLGETACTIVEATTRIBPROC GetActiveAttrib;
+ PFNGLGETACTIVESUBROUTINENAMEPROC GetActiveSubroutineName;
+ PFNGLGETACTIVESUBROUTINEUNIFORMNAMEPROC GetActiveSubroutineUniformName;
+ PFNGLGETACTIVESUBROUTINEUNIFORMIVPROC GetActiveSubroutineUniformiv;
PFNGLGETACTIVEUNIFORMPROC GetActiveUniform;
PFNGLGETACTIVEUNIFORMBLOCKNAMEPROC GetActiveUniformBlockName;
PFNGLGETACTIVEUNIFORMBLOCKIVPROC GetActiveUniformBlockiv;
@@ -1557,19 +2283,39 @@ typedef struct GladGLContext {
PFNGLGETBUFFERPOINTERVPROC GetBufferPointerv;
PFNGLGETBUFFERSUBDATAPROC GetBufferSubData;
PFNGLGETCOMPRESSEDTEXIMAGEPROC GetCompressedTexImage;
+ PFNGLGETDEBUGMESSAGELOGPROC GetDebugMessageLog;
+ PFNGLGETDOUBLEI_VPROC GetDoublei_v;
PFNGLGETDOUBLEVPROC GetDoublev;
PFNGLGETERRORPROC GetError;
+ PFNGLGETFLOATI_VPROC GetFloati_v;
PFNGLGETFLOATVPROC GetFloatv;
PFNGLGETFRAGDATAINDEXPROC GetFragDataIndex;
PFNGLGETFRAGDATALOCATIONPROC GetFragDataLocation;
PFNGLGETFRAMEBUFFERATTACHMENTPARAMETERIVPROC GetFramebufferAttachmentParameteriv;
+ PFNGLGETFRAMEBUFFERPARAMETERIVPROC GetFramebufferParameteriv;
PFNGLGETINTEGER64I_VPROC GetInteger64i_v;
PFNGLGETINTEGER64VPROC GetInteger64v;
PFNGLGETINTEGERI_VPROC GetIntegeri_v;
PFNGLGETINTEGERVPROC GetIntegerv;
+ PFNGLGETINTERNALFORMATI64VPROC GetInternalformati64v;
+ PFNGLGETINTERNALFORMATIVPROC GetInternalformativ;
PFNGLGETMULTISAMPLEFVPROC GetMultisamplefv;
+ PFNGLGETOBJECTLABELPROC GetObjectLabel;
+ PFNGLGETOBJECTPTRLABELPROC GetObjectPtrLabel;
+ PFNGLGETPOINTERVPROC GetPointerv;
+ PFNGLGETPROGRAMBINARYPROC GetProgramBinary;
PFNGLGETPROGRAMINFOLOGPROC GetProgramInfoLog;
+ PFNGLGETPROGRAMINTERFACEIVPROC GetProgramInterfaceiv;
+ PFNGLGETPROGRAMPIPELINEINFOLOGPROC GetProgramPipelineInfoLog;
+ PFNGLGETPROGRAMPIPELINEIVPROC GetProgramPipelineiv;
+ PFNGLGETPROGRAMRESOURCEINDEXPROC GetProgramResourceIndex;
+ PFNGLGETPROGRAMRESOURCELOCATIONPROC GetProgramResourceLocation;
+ PFNGLGETPROGRAMRESOURCELOCATIONINDEXPROC GetProgramResourceLocationIndex;
+ PFNGLGETPROGRAMRESOURCENAMEPROC GetProgramResourceName;
+ PFNGLGETPROGRAMRESOURCEIVPROC GetProgramResourceiv;
+ PFNGLGETPROGRAMSTAGEIVPROC GetProgramStageiv;
PFNGLGETPROGRAMIVPROC GetProgramiv;
+ PFNGLGETQUERYINDEXEDIVPROC GetQueryIndexediv;
PFNGLGETQUERYOBJECTI64VPROC GetQueryObjecti64v;
PFNGLGETQUERYOBJECTIVPROC GetQueryObjectiv;
PFNGLGETQUERYOBJECTUI64VPROC GetQueryObjectui64v;
@@ -1581,10 +2327,13 @@ typedef struct GladGLContext {
PFNGLGETSAMPLERPARAMETERFVPROC GetSamplerParameterfv;
PFNGLGETSAMPLERPARAMETERIVPROC GetSamplerParameteriv;
PFNGLGETSHADERINFOLOGPROC GetShaderInfoLog;
+ PFNGLGETSHADERPRECISIONFORMATPROC GetShaderPrecisionFormat;
PFNGLGETSHADERSOURCEPROC GetShaderSource;
PFNGLGETSHADERIVPROC GetShaderiv;
PFNGLGETSTRINGPROC GetString;
PFNGLGETSTRINGIPROC GetStringi;
+ PFNGLGETSUBROUTINEINDEXPROC GetSubroutineIndex;
+ PFNGLGETSUBROUTINEUNIFORMLOCATIONPROC GetSubroutineUniformLocation;
PFNGLGETSYNCIVPROC GetSynciv;
PFNGLGETTEXIMAGEPROC GetTexImage;
PFNGLGETTEXLEVELPARAMETERFVPROC GetTexLevelParameterfv;
@@ -1597,36 +2346,56 @@ typedef struct GladGLContext {
PFNGLGETUNIFORMBLOCKINDEXPROC GetUniformBlockIndex;
PFNGLGETUNIFORMINDICESPROC GetUniformIndices;
PFNGLGETUNIFORMLOCATIONPROC GetUniformLocation;
+ PFNGLGETUNIFORMSUBROUTINEUIVPROC GetUniformSubroutineuiv;
+ PFNGLGETUNIFORMDVPROC GetUniformdv;
PFNGLGETUNIFORMFVPROC GetUniformfv;
PFNGLGETUNIFORMIVPROC GetUniformiv;
PFNGLGETUNIFORMUIVPROC GetUniformuiv;
PFNGLGETVERTEXATTRIBIIVPROC GetVertexAttribIiv;
PFNGLGETVERTEXATTRIBIUIVPROC GetVertexAttribIuiv;
+ PFNGLGETVERTEXATTRIBLDVPROC GetVertexAttribLdv;
PFNGLGETVERTEXATTRIBPOINTERVPROC GetVertexAttribPointerv;
PFNGLGETVERTEXATTRIBDVPROC GetVertexAttribdv;
PFNGLGETVERTEXATTRIBFVPROC GetVertexAttribfv;
PFNGLGETVERTEXATTRIBIVPROC GetVertexAttribiv;
PFNGLHINTPROC Hint;
+ PFNGLINVALIDATEBUFFERDATAPROC InvalidateBufferData;
+ PFNGLINVALIDATEBUFFERSUBDATAPROC InvalidateBufferSubData;
+ PFNGLINVALIDATEFRAMEBUFFERPROC InvalidateFramebuffer;
+ PFNGLINVALIDATESUBFRAMEBUFFERPROC InvalidateSubFramebuffer;
+ PFNGLINVALIDATETEXIMAGEPROC InvalidateTexImage;
+ PFNGLINVALIDATETEXSUBIMAGEPROC InvalidateTexSubImage;
PFNGLISBUFFERPROC IsBuffer;
PFNGLISENABLEDPROC IsEnabled;
PFNGLISENABLEDIPROC IsEnabledi;
PFNGLISFRAMEBUFFERPROC IsFramebuffer;
PFNGLISPROGRAMPROC IsProgram;
+ PFNGLISPROGRAMPIPELINEPROC IsProgramPipeline;
PFNGLISQUERYPROC IsQuery;
PFNGLISRENDERBUFFERPROC IsRenderbuffer;
PFNGLISSAMPLERPROC IsSampler;
PFNGLISSHADERPROC IsShader;
PFNGLISSYNCPROC IsSync;
PFNGLISTEXTUREPROC IsTexture;
+ PFNGLISTRANSFORMFEEDBACKPROC IsTransformFeedback;
PFNGLISVERTEXARRAYPROC IsVertexArray;
PFNGLLINEWIDTHPROC LineWidth;
PFNGLLINKPROGRAMPROC LinkProgram;
PFNGLLOGICOPPROC LogicOp;
PFNGLMAPBUFFERPROC MapBuffer;
PFNGLMAPBUFFERRANGEPROC MapBufferRange;
+ PFNGLMEMORYBARRIERPROC MemoryBarrier;
+ PFNGLMINSAMPLESHADINGPROC MinSampleShading;
PFNGLMULTIDRAWARRAYSPROC MultiDrawArrays;
+ PFNGLMULTIDRAWARRAYSINDIRECTPROC MultiDrawArraysIndirect;
PFNGLMULTIDRAWELEMENTSPROC MultiDrawElements;
PFNGLMULTIDRAWELEMENTSBASEVERTEXPROC MultiDrawElementsBaseVertex;
+ PFNGLMULTIDRAWELEMENTSINDIRECTPROC MultiDrawElementsIndirect;
+ PFNGLOBJECTLABELPROC ObjectLabel;
+ PFNGLOBJECTPTRLABELPROC ObjectPtrLabel;
+ PFNGLPATCHPARAMETERFVPROC PatchParameterfv;
+ PFNGLPATCHPARAMETERIPROC PatchParameteri;
+ PFNGLPAUSETRANSFORMFEEDBACKPROC PauseTransformFeedback;
PFNGLPIXELSTOREFPROC PixelStoref;
PFNGLPIXELSTOREIPROC PixelStorei;
PFNGLPOINTPARAMETERFPROC PointParameterf;
@@ -1636,13 +2405,69 @@ typedef struct GladGLContext {
PFNGLPOINTSIZEPROC PointSize;
PFNGLPOLYGONMODEPROC PolygonMode;
PFNGLPOLYGONOFFSETPROC PolygonOffset;
+ PFNGLPOPDEBUGGROUPPROC PopDebugGroup;
PFNGLPRIMITIVERESTARTINDEXPROC PrimitiveRestartIndex;
+ PFNGLPROGRAMBINARYPROC ProgramBinary;
+ PFNGLPROGRAMPARAMETERIPROC ProgramParameteri;
+ PFNGLPROGRAMUNIFORM1DPROC ProgramUniform1d;
+ PFNGLPROGRAMUNIFORM1DVPROC ProgramUniform1dv;
+ PFNGLPROGRAMUNIFORM1FPROC ProgramUniform1f;
+ PFNGLPROGRAMUNIFORM1FVPROC ProgramUniform1fv;
+ PFNGLPROGRAMUNIFORM1IPROC ProgramUniform1i;
+ PFNGLPROGRAMUNIFORM1IVPROC ProgramUniform1iv;
+ PFNGLPROGRAMUNIFORM1UIPROC ProgramUniform1ui;
+ PFNGLPROGRAMUNIFORM1UIVPROC ProgramUniform1uiv;
+ PFNGLPROGRAMUNIFORM2DPROC ProgramUniform2d;
+ PFNGLPROGRAMUNIFORM2DVPROC ProgramUniform2dv;
+ PFNGLPROGRAMUNIFORM2FPROC ProgramUniform2f;
+ PFNGLPROGRAMUNIFORM2FVPROC ProgramUniform2fv;
+ PFNGLPROGRAMUNIFORM2IPROC ProgramUniform2i;
+ PFNGLPROGRAMUNIFORM2IVPROC ProgramUniform2iv;
+ PFNGLPROGRAMUNIFORM2UIPROC ProgramUniform2ui;
+ PFNGLPROGRAMUNIFORM2UIVPROC ProgramUniform2uiv;
+ PFNGLPROGRAMUNIFORM3DPROC ProgramUniform3d;
+ PFNGLPROGRAMUNIFORM3DVPROC ProgramUniform3dv;
+ PFNGLPROGRAMUNIFORM3FPROC ProgramUniform3f;
+ PFNGLPROGRAMUNIFORM3FVPROC ProgramUniform3fv;
+ PFNGLPROGRAMUNIFORM3IPROC ProgramUniform3i;
+ PFNGLPROGRAMUNIFORM3IVPROC ProgramUniform3iv;
+ PFNGLPROGRAMUNIFORM3UIPROC ProgramUniform3ui;
+ PFNGLPROGRAMUNIFORM3UIVPROC ProgramUniform3uiv;
+ PFNGLPROGRAMUNIFORM4DPROC ProgramUniform4d;
+ PFNGLPROGRAMUNIFORM4DVPROC ProgramUniform4dv;
+ PFNGLPROGRAMUNIFORM4FPROC ProgramUniform4f;
+ PFNGLPROGRAMUNIFORM4FVPROC ProgramUniform4fv;
+ PFNGLPROGRAMUNIFORM4IPROC ProgramUniform4i;
+ PFNGLPROGRAMUNIFORM4IVPROC ProgramUniform4iv;
+ PFNGLPROGRAMUNIFORM4UIPROC ProgramUniform4ui;
+ PFNGLPROGRAMUNIFORM4UIVPROC ProgramUniform4uiv;
+ PFNGLPROGRAMUNIFORMMATRIX2DVPROC ProgramUniformMatrix2dv;
+ PFNGLPROGRAMUNIFORMMATRIX2FVPROC ProgramUniformMatrix2fv;
+ PFNGLPROGRAMUNIFORMMATRIX2X3DVPROC ProgramUniformMatrix2x3dv;
+ PFNGLPROGRAMUNIFORMMATRIX2X3FVPROC ProgramUniformMatrix2x3fv;
+ PFNGLPROGRAMUNIFORMMATRIX2X4DVPROC ProgramUniformMatrix2x4dv;
+ PFNGLPROGRAMUNIFORMMATRIX2X4FVPROC ProgramUniformMatrix2x4fv;
+ PFNGLPROGRAMUNIFORMMATRIX3DVPROC ProgramUniformMatrix3dv;
+ PFNGLPROGRAMUNIFORMMATRIX3FVPROC ProgramUniformMatrix3fv;
+ PFNGLPROGRAMUNIFORMMATRIX3X2DVPROC ProgramUniformMatrix3x2dv;
+ PFNGLPROGRAMUNIFORMMATRIX3X2FVPROC ProgramUniformMatrix3x2fv;
+ PFNGLPROGRAMUNIFORMMATRIX3X4DVPROC ProgramUniformMatrix3x4dv;
+ PFNGLPROGRAMUNIFORMMATRIX3X4FVPROC ProgramUniformMatrix3x4fv;
+ PFNGLPROGRAMUNIFORMMATRIX4DVPROC ProgramUniformMatrix4dv;
+ PFNGLPROGRAMUNIFORMMATRIX4FVPROC ProgramUniformMatrix4fv;
+ PFNGLPROGRAMUNIFORMMATRIX4X2DVPROC ProgramUniformMatrix4x2dv;
+ PFNGLPROGRAMUNIFORMMATRIX4X2FVPROC ProgramUniformMatrix4x2fv;
+ PFNGLPROGRAMUNIFORMMATRIX4X3DVPROC ProgramUniformMatrix4x3dv;
+ PFNGLPROGRAMUNIFORMMATRIX4X3FVPROC ProgramUniformMatrix4x3fv;
PFNGLPROVOKINGVERTEXPROC ProvokingVertex;
+ PFNGLPUSHDEBUGGROUPPROC PushDebugGroup;
PFNGLQUERYCOUNTERPROC QueryCounter;
PFNGLREADBUFFERPROC ReadBuffer;
PFNGLREADPIXELSPROC ReadPixels;
+ PFNGLRELEASESHADERCOMPILERPROC ReleaseShaderCompiler;
PFNGLRENDERBUFFERSTORAGEPROC RenderbufferStorage;
PFNGLRENDERBUFFERSTORAGEMULTISAMPLEPROC RenderbufferStorageMultisample;
+ PFNGLRESUMETRANSFORMFEEDBACKPROC ResumeTransformFeedback;
PFNGLSAMPLECOVERAGEPROC SampleCoverage;
PFNGLSAMPLEMASKIPROC SampleMaski;
PFNGLSAMPLERPARAMETERIIVPROC SamplerParameterIiv;
@@ -1652,7 +2477,12 @@ typedef struct GladGLContext {
PFNGLSAMPLERPARAMETERIPROC SamplerParameteri;
PFNGLSAMPLERPARAMETERIVPROC SamplerParameteriv;
PFNGLSCISSORPROC Scissor;
+ PFNGLSCISSORARRAYVPROC ScissorArrayv;
+ PFNGLSCISSORINDEXEDPROC ScissorIndexed;
+ PFNGLSCISSORINDEXEDVPROC ScissorIndexedv;
+ PFNGLSHADERBINARYPROC ShaderBinary;
PFNGLSHADERSOURCEPROC ShaderSource;
+ PFNGLSHADERSTORAGEBLOCKBINDINGPROC ShaderStorageBlockBinding;
PFNGLSTENCILFUNCPROC StencilFunc;
PFNGLSTENCILFUNCSEPARATEPROC StencilFuncSeparate;
PFNGLSTENCILMASKPROC StencilMask;
@@ -1660,6 +2490,7 @@ typedef struct GladGLContext {
PFNGLSTENCILOPPROC StencilOp;
PFNGLSTENCILOPSEPARATEPROC StencilOpSeparate;
PFNGLTEXBUFFERPROC TexBuffer;
+ PFNGLTEXBUFFERRANGEPROC TexBufferRange;
PFNGLTEXIMAGE1DPROC TexImage1D;
PFNGLTEXIMAGE2DPROC TexImage2D;
PFNGLTEXIMAGE2DMULTISAMPLEPROC TexImage2DMultisample;
@@ -1671,28 +2502,42 @@ typedef struct GladGLContext {
PFNGLTEXPARAMETERFVPROC TexParameterfv;
PFNGLTEXPARAMETERIPROC TexParameteri;
PFNGLTEXPARAMETERIVPROC TexParameteriv;
+ PFNGLTEXSTORAGE1DPROC TexStorage1D;
+ PFNGLTEXSTORAGE2DPROC TexStorage2D;
+ PFNGLTEXSTORAGE2DMULTISAMPLEPROC TexStorage2DMultisample;
+ PFNGLTEXSTORAGE3DPROC TexStorage3D;
+ PFNGLTEXSTORAGE3DMULTISAMPLEPROC TexStorage3DMultisample;
PFNGLTEXSUBIMAGE1DPROC TexSubImage1D;
PFNGLTEXSUBIMAGE2DPROC TexSubImage2D;
PFNGLTEXSUBIMAGE3DPROC TexSubImage3D;
+ PFNGLTEXTUREVIEWPROC TextureView;
PFNGLTRANSFORMFEEDBACKVARYINGSPROC TransformFeedbackVaryings;
+ PFNGLUNIFORM1DPROC Uniform1d;
+ PFNGLUNIFORM1DVPROC Uniform1dv;
PFNGLUNIFORM1FPROC Uniform1f;
PFNGLUNIFORM1FVPROC Uniform1fv;
PFNGLUNIFORM1IPROC Uniform1i;
PFNGLUNIFORM1IVPROC Uniform1iv;
PFNGLUNIFORM1UIPROC Uniform1ui;
PFNGLUNIFORM1UIVPROC Uniform1uiv;
+ PFNGLUNIFORM2DPROC Uniform2d;
+ PFNGLUNIFORM2DVPROC Uniform2dv;
PFNGLUNIFORM2FPROC Uniform2f;
PFNGLUNIFORM2FVPROC Uniform2fv;
PFNGLUNIFORM2IPROC Uniform2i;
PFNGLUNIFORM2IVPROC Uniform2iv;
PFNGLUNIFORM2UIPROC Uniform2ui;
PFNGLUNIFORM2UIVPROC Uniform2uiv;
+ PFNGLUNIFORM3DPROC Uniform3d;
+ PFNGLUNIFORM3DVPROC Uniform3dv;
PFNGLUNIFORM3FPROC Uniform3f;
PFNGLUNIFORM3FVPROC Uniform3fv;
PFNGLUNIFORM3IPROC Uniform3i;
PFNGLUNIFORM3IVPROC Uniform3iv;
PFNGLUNIFORM3UIPROC Uniform3ui;
PFNGLUNIFORM3UIVPROC Uniform3uiv;
+ PFNGLUNIFORM4DPROC Uniform4d;
+ PFNGLUNIFORM4DVPROC Uniform4dv;
PFNGLUNIFORM4FPROC Uniform4f;
PFNGLUNIFORM4FVPROC Uniform4fv;
PFNGLUNIFORM4IPROC Uniform4i;
@@ -1700,18 +2545,30 @@ typedef struct GladGLContext {
PFNGLUNIFORM4UIPROC Uniform4ui;
PFNGLUNIFORM4UIVPROC Uniform4uiv;
PFNGLUNIFORMBLOCKBINDINGPROC UniformBlockBinding;
+ PFNGLUNIFORMMATRIX2DVPROC UniformMatrix2dv;
PFNGLUNIFORMMATRIX2FVPROC UniformMatrix2fv;
+ PFNGLUNIFORMMATRIX2X3DVPROC UniformMatrix2x3dv;
PFNGLUNIFORMMATRIX2X3FVPROC UniformMatrix2x3fv;
+ PFNGLUNIFORMMATRIX2X4DVPROC UniformMatrix2x4dv;
PFNGLUNIFORMMATRIX2X4FVPROC UniformMatrix2x4fv;
+ PFNGLUNIFORMMATRIX3DVPROC UniformMatrix3dv;
PFNGLUNIFORMMATRIX3FVPROC UniformMatrix3fv;
+ PFNGLUNIFORMMATRIX3X2DVPROC UniformMatrix3x2dv;
PFNGLUNIFORMMATRIX3X2FVPROC UniformMatrix3x2fv;
+ PFNGLUNIFORMMATRIX3X4DVPROC UniformMatrix3x4dv;
PFNGLUNIFORMMATRIX3X4FVPROC UniformMatrix3x4fv;
+ PFNGLUNIFORMMATRIX4DVPROC UniformMatrix4dv;
PFNGLUNIFORMMATRIX4FVPROC UniformMatrix4fv;
+ PFNGLUNIFORMMATRIX4X2DVPROC UniformMatrix4x2dv;
PFNGLUNIFORMMATRIX4X2FVPROC UniformMatrix4x2fv;
+ PFNGLUNIFORMMATRIX4X3DVPROC UniformMatrix4x3dv;
PFNGLUNIFORMMATRIX4X3FVPROC UniformMatrix4x3fv;
+ PFNGLUNIFORMSUBROUTINESUIVPROC UniformSubroutinesuiv;
PFNGLUNMAPBUFFERPROC UnmapBuffer;
PFNGLUSEPROGRAMPROC UseProgram;
+ PFNGLUSEPROGRAMSTAGESPROC UseProgramStages;
PFNGLVALIDATEPROGRAMPROC ValidateProgram;
+ PFNGLVALIDATEPROGRAMPIPELINEPROC ValidateProgramPipeline;
PFNGLVERTEXATTRIB1DPROC VertexAttrib1d;
PFNGLVERTEXATTRIB1DVPROC VertexAttrib1dv;
PFNGLVERTEXATTRIB1FPROC VertexAttrib1f;
@@ -1748,7 +2605,9 @@ typedef struct GladGLContext {
PFNGLVERTEXATTRIB4UBVPROC VertexAttrib4ubv;
PFNGLVERTEXATTRIB4UIVPROC VertexAttrib4uiv;
PFNGLVERTEXATTRIB4USVPROC VertexAttrib4usv;
+ PFNGLVERTEXATTRIBBINDINGPROC VertexAttribBinding;
PFNGLVERTEXATTRIBDIVISORPROC VertexAttribDivisor;
+ PFNGLVERTEXATTRIBFORMATPROC VertexAttribFormat;
PFNGLVERTEXATTRIBI1IPROC VertexAttribI1i;
PFNGLVERTEXATTRIBI1IVPROC VertexAttribI1iv;
PFNGLVERTEXATTRIBI1UIPROC VertexAttribI1ui;
@@ -1769,7 +2628,18 @@ typedef struct GladGLContext {
PFNGLVERTEXATTRIBI4UIPROC VertexAttribI4ui;
PFNGLVERTEXATTRIBI4UIVPROC VertexAttribI4uiv;
PFNGLVERTEXATTRIBI4USVPROC VertexAttribI4usv;
+ PFNGLVERTEXATTRIBIFORMATPROC VertexAttribIFormat;
PFNGLVERTEXATTRIBIPOINTERPROC VertexAttribIPointer;
+ PFNGLVERTEXATTRIBL1DPROC VertexAttribL1d;
+ PFNGLVERTEXATTRIBL1DVPROC VertexAttribL1dv;
+ PFNGLVERTEXATTRIBL2DPROC VertexAttribL2d;
+ PFNGLVERTEXATTRIBL2DVPROC VertexAttribL2dv;
+ PFNGLVERTEXATTRIBL3DPROC VertexAttribL3d;
+ PFNGLVERTEXATTRIBL3DVPROC VertexAttribL3dv;
+ PFNGLVERTEXATTRIBL4DPROC VertexAttribL4d;
+ PFNGLVERTEXATTRIBL4DVPROC VertexAttribL4dv;
+ PFNGLVERTEXATTRIBLFORMATPROC VertexAttribLFormat;
+ PFNGLVERTEXATTRIBLPOINTERPROC VertexAttribLPointer;
PFNGLVERTEXATTRIBP1UIPROC VertexAttribP1ui;
PFNGLVERTEXATTRIBP1UIVPROC VertexAttribP1uiv;
PFNGLVERTEXATTRIBP2UIPROC VertexAttribP2ui;
@@ -1779,7 +2649,11 @@ typedef struct GladGLContext {
PFNGLVERTEXATTRIBP4UIPROC VertexAttribP4ui;
PFNGLVERTEXATTRIBP4UIVPROC VertexAttribP4uiv;
PFNGLVERTEXATTRIBPOINTERPROC VertexAttribPointer;
+ PFNGLVERTEXBINDINGDIVISORPROC VertexBindingDivisor;
PFNGLVIEWPORTPROC Viewport;
+ PFNGLVIEWPORTARRAYVPROC ViewportArrayv;
+ PFNGLVIEWPORTINDEXEDFPROC ViewportIndexedf;
+ PFNGLVIEWPORTINDEXEDFVPROC ViewportIndexedfv;
PFNGLWAITSYNCPROC WaitSync;
void* glad_loader_handle;
diff --git a/vendor/glad/include/glad/glad.h b/vendor/glad/include/glad/glad.h
deleted file mode 100644
index f70d5b73f..000000000
--- a/vendor/glad/include/glad/glad.h
+++ /dev/null
@@ -1 +0,0 @@
-#include <glad/gl.h>
diff --git a/vendor/glad/src/gl.c b/vendor/glad/src/gl.c
index ad49f387a..3eaf35450 100644
--- a/vendor/glad/src/gl.c
+++ b/vendor/glad/src/gl.c
@@ -90,6 +90,7 @@ static void glad_gl_load_GL_VERSION_1_1(GladGLContext *context, GLADuserptrloadf
context->DrawArrays = (PFNGLDRAWARRAYSPROC) load(userptr, "glDrawArrays");
context->DrawElements = (PFNGLDRAWELEMENTSPROC) load(userptr, "glDrawElements");
context->GenTextures = (PFNGLGENTEXTURESPROC) load(userptr, "glGenTextures");
+ context->GetPointerv = (PFNGLGETPOINTERVPROC) load(userptr, "glGetPointerv");
context->IsTexture = (PFNGLISTEXTUREPROC) load(userptr, "glIsTexture");
context->PolygonOffset = (PFNGLPOLYGONOFFSETPROC) load(userptr, "glPolygonOffset");
context->TexSubImage1D = (PFNGLTEXSUBIMAGE1DPROC) load(userptr, "glTexSubImage1D");
@@ -411,39 +412,229 @@ static void glad_gl_load_GL_VERSION_3_3(GladGLContext *context, GLADuserptrloadf
context->VertexAttribP4ui = (PFNGLVERTEXATTRIBP4UIPROC) load(userptr, "glVertexAttribP4ui");
context->VertexAttribP4uiv = (PFNGLVERTEXATTRIBP4UIVPROC) load(userptr, "glVertexAttribP4uiv");
}
+static void glad_gl_load_GL_VERSION_4_0(GladGLContext *context, GLADuserptrloadfunc load, void* userptr) {
+ if(!context->VERSION_4_0) return;
+ context->BeginQueryIndexed = (PFNGLBEGINQUERYINDEXEDPROC) load(userptr, "glBeginQueryIndexed");
+ context->BindTransformFeedback = (PFNGLBINDTRANSFORMFEEDBACKPROC) load(userptr, "glBindTransformFeedback");
+ context->BlendEquationSeparatei = (PFNGLBLENDEQUATIONSEPARATEIPROC) load(userptr, "glBlendEquationSeparatei");
+ context->BlendEquationi = (PFNGLBLENDEQUATIONIPROC) load(userptr, "glBlendEquationi");
+ context->BlendFuncSeparatei = (PFNGLBLENDFUNCSEPARATEIPROC) load(userptr, "glBlendFuncSeparatei");
+ context->BlendFunci = (PFNGLBLENDFUNCIPROC) load(userptr, "glBlendFunci");
+ context->DeleteTransformFeedbacks = (PFNGLDELETETRANSFORMFEEDBACKSPROC) load(userptr, "glDeleteTransformFeedbacks");
+ context->DrawArraysIndirect = (PFNGLDRAWARRAYSINDIRECTPROC) load(userptr, "glDrawArraysIndirect");
+ context->DrawElementsIndirect = (PFNGLDRAWELEMENTSINDIRECTPROC) load(userptr, "glDrawElementsIndirect");
+ context->DrawTransformFeedback = (PFNGLDRAWTRANSFORMFEEDBACKPROC) load(userptr, "glDrawTransformFeedback");
+ context->DrawTransformFeedbackStream = (PFNGLDRAWTRANSFORMFEEDBACKSTREAMPROC) load(userptr, "glDrawTransformFeedbackStream");
+ context->EndQueryIndexed = (PFNGLENDQUERYINDEXEDPROC) load(userptr, "glEndQueryIndexed");
+ context->GenTransformFeedbacks = (PFNGLGENTRANSFORMFEEDBACKSPROC) load(userptr, "glGenTransformFeedbacks");
+ context->GetActiveSubroutineName = (PFNGLGETACTIVESUBROUTINENAMEPROC) load(userptr, "glGetActiveSubroutineName");
+ context->GetActiveSubroutineUniformName = (PFNGLGETACTIVESUBROUTINEUNIFORMNAMEPROC) load(userptr, "glGetActiveSubroutineUniformName");
+ context->GetActiveSubroutineUniformiv = (PFNGLGETACTIVESUBROUTINEUNIFORMIVPROC) load(userptr, "glGetActiveSubroutineUniformiv");
+ context->GetProgramStageiv = (PFNGLGETPROGRAMSTAGEIVPROC) load(userptr, "glGetProgramStageiv");
+ context->GetQueryIndexediv = (PFNGLGETQUERYINDEXEDIVPROC) load(userptr, "glGetQueryIndexediv");
+ context->GetSubroutineIndex = (PFNGLGETSUBROUTINEINDEXPROC) load(userptr, "glGetSubroutineIndex");
+ context->GetSubroutineUniformLocation = (PFNGLGETSUBROUTINEUNIFORMLOCATIONPROC) load(userptr, "glGetSubroutineUniformLocation");
+ context->GetUniformSubroutineuiv = (PFNGLGETUNIFORMSUBROUTINEUIVPROC) load(userptr, "glGetUniformSubroutineuiv");
+ context->GetUniformdv = (PFNGLGETUNIFORMDVPROC) load(userptr, "glGetUniformdv");
+ context->IsTransformFeedback = (PFNGLISTRANSFORMFEEDBACKPROC) load(userptr, "glIsTransformFeedback");
+ context->MinSampleShading = (PFNGLMINSAMPLESHADINGPROC) load(userptr, "glMinSampleShading");
+ context->PatchParameterfv = (PFNGLPATCHPARAMETERFVPROC) load(userptr, "glPatchParameterfv");
+ context->PatchParameteri = (PFNGLPATCHPARAMETERIPROC) load(userptr, "glPatchParameteri");
+ context->PauseTransformFeedback = (PFNGLPAUSETRANSFORMFEEDBACKPROC) load(userptr, "glPauseTransformFeedback");
+ context->ResumeTransformFeedback = (PFNGLRESUMETRANSFORMFEEDBACKPROC) load(userptr, "glResumeTransformFeedback");
+ context->Uniform1d = (PFNGLUNIFORM1DPROC) load(userptr, "glUniform1d");
+ context->Uniform1dv = (PFNGLUNIFORM1DVPROC) load(userptr, "glUniform1dv");
+ context->Uniform2d = (PFNGLUNIFORM2DPROC) load(userptr, "glUniform2d");
+ context->Uniform2dv = (PFNGLUNIFORM2DVPROC) load(userptr, "glUniform2dv");
+ context->Uniform3d = (PFNGLUNIFORM3DPROC) load(userptr, "glUniform3d");
+ context->Uniform3dv = (PFNGLUNIFORM3DVPROC) load(userptr, "glUniform3dv");
+ context->Uniform4d = (PFNGLUNIFORM4DPROC) load(userptr, "glUniform4d");
+ context->Uniform4dv = (PFNGLUNIFORM4DVPROC) load(userptr, "glUniform4dv");
+ context->UniformMatrix2dv = (PFNGLUNIFORMMATRIX2DVPROC) load(userptr, "glUniformMatrix2dv");
+ context->UniformMatrix2x3dv = (PFNGLUNIFORMMATRIX2X3DVPROC) load(userptr, "glUniformMatrix2x3dv");
+ context->UniformMatrix2x4dv = (PFNGLUNIFORMMATRIX2X4DVPROC) load(userptr, "glUniformMatrix2x4dv");
+ context->UniformMatrix3dv = (PFNGLUNIFORMMATRIX3DVPROC) load(userptr, "glUniformMatrix3dv");
+ context->UniformMatrix3x2dv = (PFNGLUNIFORMMATRIX3X2DVPROC) load(userptr, "glUniformMatrix3x2dv");
+ context->UniformMatrix3x4dv = (PFNGLUNIFORMMATRIX3X4DVPROC) load(userptr, "glUniformMatrix3x4dv");
+ context->UniformMatrix4dv = (PFNGLUNIFORMMATRIX4DVPROC) load(userptr, "glUniformMatrix4dv");
+ context->UniformMatrix4x2dv = (PFNGLUNIFORMMATRIX4X2DVPROC) load(userptr, "glUniformMatrix4x2dv");
+ context->UniformMatrix4x3dv = (PFNGLUNIFORMMATRIX4X3DVPROC) load(userptr, "glUniformMatrix4x3dv");
+ context->UniformSubroutinesuiv = (PFNGLUNIFORMSUBROUTINESUIVPROC) load(userptr, "glUniformSubroutinesuiv");
+}
+static void glad_gl_load_GL_VERSION_4_1(GladGLContext *context, GLADuserptrloadfunc load, void* userptr) {
+ if(!context->VERSION_4_1) return;
+ context->ActiveShaderProgram = (PFNGLACTIVESHADERPROGRAMPROC) load(userptr, "glActiveShaderProgram");
+ context->BindProgramPipeline = (PFNGLBINDPROGRAMPIPELINEPROC) load(userptr, "glBindProgramPipeline");
+ context->ClearDepthf = (PFNGLCLEARDEPTHFPROC) load(userptr, "glClearDepthf");
+ context->CreateShaderProgramv = (PFNGLCREATESHADERPROGRAMVPROC) load(userptr, "glCreateShaderProgramv");
+ context->DeleteProgramPipelines = (PFNGLDELETEPROGRAMPIPELINESPROC) load(userptr, "glDeleteProgramPipelines");
+ context->DepthRangeArrayv = (PFNGLDEPTHRANGEARRAYVPROC) load(userptr, "glDepthRangeArrayv");
+ context->DepthRangeIndexed = (PFNGLDEPTHRANGEINDEXEDPROC) load(userptr, "glDepthRangeIndexed");
+ context->DepthRangef = (PFNGLDEPTHRANGEFPROC) load(userptr, "glDepthRangef");
+ context->GenProgramPipelines = (PFNGLGENPROGRAMPIPELINESPROC) load(userptr, "glGenProgramPipelines");
+ context->GetDoublei_v = (PFNGLGETDOUBLEI_VPROC) load(userptr, "glGetDoublei_v");
+ context->GetFloati_v = (PFNGLGETFLOATI_VPROC) load(userptr, "glGetFloati_v");
+ context->GetProgramBinary = (PFNGLGETPROGRAMBINARYPROC) load(userptr, "glGetProgramBinary");
+ context->GetProgramPipelineInfoLog = (PFNGLGETPROGRAMPIPELINEINFOLOGPROC) load(userptr, "glGetProgramPipelineInfoLog");
+ context->GetProgramPipelineiv = (PFNGLGETPROGRAMPIPELINEIVPROC) load(userptr, "glGetProgramPipelineiv");
+ context->GetShaderPrecisionFormat = (PFNGLGETSHADERPRECISIONFORMATPROC) load(userptr, "glGetShaderPrecisionFormat");
+ context->GetVertexAttribLdv = (PFNGLGETVERTEXATTRIBLDVPROC) load(userptr, "glGetVertexAttribLdv");
+ context->IsProgramPipeline = (PFNGLISPROGRAMPIPELINEPROC) load(userptr, "glIsProgramPipeline");
+ context->ProgramBinary = (PFNGLPROGRAMBINARYPROC) load(userptr, "glProgramBinary");
+ context->ProgramParameteri = (PFNGLPROGRAMPARAMETERIPROC) load(userptr, "glProgramParameteri");
+ context->ProgramUniform1d = (PFNGLPROGRAMUNIFORM1DPROC) load(userptr, "glProgramUniform1d");
+ context->ProgramUniform1dv = (PFNGLPROGRAMUNIFORM1DVPROC) load(userptr, "glProgramUniform1dv");
+ context->ProgramUniform1f = (PFNGLPROGRAMUNIFORM1FPROC) load(userptr, "glProgramUniform1f");
+ context->ProgramUniform1fv = (PFNGLPROGRAMUNIFORM1FVPROC) load(userptr, "glProgramUniform1fv");
+ context->ProgramUniform1i = (PFNGLPROGRAMUNIFORM1IPROC) load(userptr, "glProgramUniform1i");
+ context->ProgramUniform1iv = (PFNGLPROGRAMUNIFORM1IVPROC) load(userptr, "glProgramUniform1iv");
+ context->ProgramUniform1ui = (PFNGLPROGRAMUNIFORM1UIPROC) load(userptr, "glProgramUniform1ui");
+ context->ProgramUniform1uiv = (PFNGLPROGRAMUNIFORM1UIVPROC) load(userptr, "glProgramUniform1uiv");
+ context->ProgramUniform2d = (PFNGLPROGRAMUNIFORM2DPROC) load(userptr, "glProgramUniform2d");
+ context->ProgramUniform2dv = (PFNGLPROGRAMUNIFORM2DVPROC) load(userptr, "glProgramUniform2dv");
+ context->ProgramUniform2f = (PFNGLPROGRAMUNIFORM2FPROC) load(userptr, "glProgramUniform2f");
+ context->ProgramUniform2fv = (PFNGLPROGRAMUNIFORM2FVPROC) load(userptr, "glProgramUniform2fv");
+ context->ProgramUniform2i = (PFNGLPROGRAMUNIFORM2IPROC) load(userptr, "glProgramUniform2i");
+ context->ProgramUniform2iv = (PFNGLPROGRAMUNIFORM2IVPROC) load(userptr, "glProgramUniform2iv");
+ context->ProgramUniform2ui = (PFNGLPROGRAMUNIFORM2UIPROC) load(userptr, "glProgramUniform2ui");
+ context->ProgramUniform2uiv = (PFNGLPROGRAMUNIFORM2UIVPROC) load(userptr, "glProgramUniform2uiv");
+ context->ProgramUniform3d = (PFNGLPROGRAMUNIFORM3DPROC) load(userptr, "glProgramUniform3d");
+ context->ProgramUniform3dv = (PFNGLPROGRAMUNIFORM3DVPROC) load(userptr, "glProgramUniform3dv");
+ context->ProgramUniform3f = (PFNGLPROGRAMUNIFORM3FPROC) load(userptr, "glProgramUniform3f");
+ context->ProgramUniform3fv = (PFNGLPROGRAMUNIFORM3FVPROC) load(userptr, "glProgramUniform3fv");
+ context->ProgramUniform3i = (PFNGLPROGRAMUNIFORM3IPROC) load(userptr, "glProgramUniform3i");
+ context->ProgramUniform3iv = (PFNGLPROGRAMUNIFORM3IVPROC) load(userptr, "glProgramUniform3iv");
+ context->ProgramUniform3ui = (PFNGLPROGRAMUNIFORM3UIPROC) load(userptr, "glProgramUniform3ui");
+ context->ProgramUniform3uiv = (PFNGLPROGRAMUNIFORM3UIVPROC) load(userptr, "glProgramUniform3uiv");
+ context->ProgramUniform4d = (PFNGLPROGRAMUNIFORM4DPROC) load(userptr, "glProgramUniform4d");
+ context->ProgramUniform4dv = (PFNGLPROGRAMUNIFORM4DVPROC) load(userptr, "glProgramUniform4dv");
+ context->ProgramUniform4f = (PFNGLPROGRAMUNIFORM4FPROC) load(userptr, "glProgramUniform4f");
+ context->ProgramUniform4fv = (PFNGLPROGRAMUNIFORM4FVPROC) load(userptr, "glProgramUniform4fv");
+ context->ProgramUniform4i = (PFNGLPROGRAMUNIFORM4IPROC) load(userptr, "glProgramUniform4i");
+ context->ProgramUniform4iv = (PFNGLPROGRAMUNIFORM4IVPROC) load(userptr, "glProgramUniform4iv");
+ context->ProgramUniform4ui = (PFNGLPROGRAMUNIFORM4UIPROC) load(userptr, "glProgramUniform4ui");
+ context->ProgramUniform4uiv = (PFNGLPROGRAMUNIFORM4UIVPROC) load(userptr, "glProgramUniform4uiv");
+ context->ProgramUniformMatrix2dv = (PFNGLPROGRAMUNIFORMMATRIX2DVPROC) load(userptr, "glProgramUniformMatrix2dv");
+ context->ProgramUniformMatrix2fv = (PFNGLPROGRAMUNIFORMMATRIX2FVPROC) load(userptr, "glProgramUniformMatrix2fv");
+ context->ProgramUniformMatrix2x3dv = (PFNGLPROGRAMUNIFORMMATRIX2X3DVPROC) load(userptr, "glProgramUniformMatrix2x3dv");
+ context->ProgramUniformMatrix2x3fv = (PFNGLPROGRAMUNIFORMMATRIX2X3FVPROC) load(userptr, "glProgramUniformMatrix2x3fv");
+ context->ProgramUniformMatrix2x4dv = (PFNGLPROGRAMUNIFORMMATRIX2X4DVPROC) load(userptr, "glProgramUniformMatrix2x4dv");
+ context->ProgramUniformMatrix2x4fv = (PFNGLPROGRAMUNIFORMMATRIX2X4FVPROC) load(userptr, "glProgramUniformMatrix2x4fv");
+ context->ProgramUniformMatrix3dv = (PFNGLPROGRAMUNIFORMMATRIX3DVPROC) load(userptr, "glProgramUniformMatrix3dv");
+ context->ProgramUniformMatrix3fv = (PFNGLPROGRAMUNIFORMMATRIX3FVPROC) load(userptr, "glProgramUniformMatrix3fv");
+ context->ProgramUniformMatrix3x2dv = (PFNGLPROGRAMUNIFORMMATRIX3X2DVPROC) load(userptr, "glProgramUniformMatrix3x2dv");
+ context->ProgramUniformMatrix3x2fv = (PFNGLPROGRAMUNIFORMMATRIX3X2FVPROC) load(userptr, "glProgramUniformMatrix3x2fv");
+ context->ProgramUniformMatrix3x4dv = (PFNGLPROGRAMUNIFORMMATRIX3X4DVPROC) load(userptr, "glProgramUniformMatrix3x4dv");
+ context->ProgramUniformMatrix3x4fv = (PFNGLPROGRAMUNIFORMMATRIX3X4FVPROC) load(userptr, "glProgramUniformMatrix3x4fv");
+ context->ProgramUniformMatrix4dv = (PFNGLPROGRAMUNIFORMMATRIX4DVPROC) load(userptr, "glProgramUniformMatrix4dv");
+ context->ProgramUniformMatrix4fv = (PFNGLPROGRAMUNIFORMMATRIX4FVPROC) load(userptr, "glProgramUniformMatrix4fv");
+ context->ProgramUniformMatrix4x2dv = (PFNGLPROGRAMUNIFORMMATRIX4X2DVPROC) load(userptr, "glProgramUniformMatrix4x2dv");
+ context->ProgramUniformMatrix4x2fv = (PFNGLPROGRAMUNIFORMMATRIX4X2FVPROC) load(userptr, "glProgramUniformMatrix4x2fv");
+ context->ProgramUniformMatrix4x3dv = (PFNGLPROGRAMUNIFORMMATRIX4X3DVPROC) load(userptr, "glProgramUniformMatrix4x3dv");
+ context->ProgramUniformMatrix4x3fv = (PFNGLPROGRAMUNIFORMMATRIX4X3FVPROC) load(userptr, "glProgramUniformMatrix4x3fv");
+ context->ReleaseShaderCompiler = (PFNGLRELEASESHADERCOMPILERPROC) load(userptr, "glReleaseShaderCompiler");
+ context->ScissorArrayv = (PFNGLSCISSORARRAYVPROC) load(userptr, "glScissorArrayv");
+ context->ScissorIndexed = (PFNGLSCISSORINDEXEDPROC) load(userptr, "glScissorIndexed");
+ context->ScissorIndexedv = (PFNGLSCISSORINDEXEDVPROC) load(userptr, "glScissorIndexedv");
+ context->ShaderBinary = (PFNGLSHADERBINARYPROC) load(userptr, "glShaderBinary");
+ context->UseProgramStages = (PFNGLUSEPROGRAMSTAGESPROC) load(userptr, "glUseProgramStages");
+ context->ValidateProgramPipeline = (PFNGLVALIDATEPROGRAMPIPELINEPROC) load(userptr, "glValidateProgramPipeline");
+ context->VertexAttribL1d = (PFNGLVERTEXATTRIBL1DPROC) load(userptr, "glVertexAttribL1d");
+ context->VertexAttribL1dv = (PFNGLVERTEXATTRIBL1DVPROC) load(userptr, "glVertexAttribL1dv");
+ context->VertexAttribL2d = (PFNGLVERTEXATTRIBL2DPROC) load(userptr, "glVertexAttribL2d");
+ context->VertexAttribL2dv = (PFNGLVERTEXATTRIBL2DVPROC) load(userptr, "glVertexAttribL2dv");
+ context->VertexAttribL3d = (PFNGLVERTEXATTRIBL3DPROC) load(userptr, "glVertexAttribL3d");
+ context->VertexAttribL3dv = (PFNGLVERTEXATTRIBL3DVPROC) load(userptr, "glVertexAttribL3dv");
+ context->VertexAttribL4d = (PFNGLVERTEXATTRIBL4DPROC) load(userptr, "glVertexAttribL4d");
+ context->VertexAttribL4dv = (PFNGLVERTEXATTRIBL4DVPROC) load(userptr, "glVertexAttribL4dv");
+ context->VertexAttribLPointer = (PFNGLVERTEXATTRIBLPOINTERPROC) load(userptr, "glVertexAttribLPointer");
+ context->ViewportArrayv = (PFNGLVIEWPORTARRAYVPROC) load(userptr, "glViewportArrayv");
+ context->ViewportIndexedf = (PFNGLVIEWPORTINDEXEDFPROC) load(userptr, "glViewportIndexedf");
+ context->ViewportIndexedfv = (PFNGLVIEWPORTINDEXEDFVPROC) load(userptr, "glViewportIndexedfv");
+}
+static void glad_gl_load_GL_VERSION_4_2(GladGLContext *context, GLADuserptrloadfunc load, void* userptr) {
+ if(!context->VERSION_4_2) return;
+ context->BindImageTexture = (PFNGLBINDIMAGETEXTUREPROC) load(userptr, "glBindImageTexture");
+ context->DrawArraysInstancedBaseInstance = (PFNGLDRAWARRAYSINSTANCEDBASEINSTANCEPROC) load(userptr, "glDrawArraysInstancedBaseInstance");
+ context->DrawElementsInstancedBaseInstance = (PFNGLDRAWELEMENTSINSTANCEDBASEINSTANCEPROC) load(userptr, "glDrawElementsInstancedBaseInstance");
+ context->DrawElementsInstancedBaseVertexBaseInstance = (PFNGLDRAWELEMENTSINSTANCEDBASEVERTEXBASEINSTANCEPROC) load(userptr, "glDrawElementsInstancedBaseVertexBaseInstance");
+ context->DrawTransformFeedbackInstanced = (PFNGLDRAWTRANSFORMFEEDBACKINSTANCEDPROC) load(userptr, "glDrawTransformFeedbackInstanced");
+ context->DrawTransformFeedbackStreamInstanced = (PFNGLDRAWTRANSFORMFEEDBACKSTREAMINSTANCEDPROC) load(userptr, "glDrawTransformFeedbackStreamInstanced");
+ context->GetActiveAtomicCounterBufferiv = (PFNGLGETACTIVEATOMICCOUNTERBUFFERIVPROC) load(userptr, "glGetActiveAtomicCounterBufferiv");
+ context->GetInternalformativ = (PFNGLGETINTERNALFORMATIVPROC) load(userptr, "glGetInternalformativ");
+ context->MemoryBarrier = (PFNGLMEMORYBARRIERPROC) load(userptr, "glMemoryBarrier");
+ context->TexStorage1D = (PFNGLTEXSTORAGE1DPROC) load(userptr, "glTexStorage1D");
+ context->TexStorage2D = (PFNGLTEXSTORAGE2DPROC) load(userptr, "glTexStorage2D");
+ context->TexStorage3D = (PFNGLTEXSTORAGE3DPROC) load(userptr, "glTexStorage3D");
+}
+static void glad_gl_load_GL_VERSION_4_3(GladGLContext *context, GLADuserptrloadfunc load, void* userptr) {
+ if(!context->VERSION_4_3) return;
+ context->BindVertexBuffer = (PFNGLBINDVERTEXBUFFERPROC) load(userptr, "glBindVertexBuffer");
+ context->ClearBufferData = (PFNGLCLEARBUFFERDATAPROC) load(userptr, "glClearBufferData");
+ context->ClearBufferSubData = (PFNGLCLEARBUFFERSUBDATAPROC) load(userptr, "glClearBufferSubData");
+ context->CopyImageSubData = (PFNGLCOPYIMAGESUBDATAPROC) load(userptr, "glCopyImageSubData");
+ context->DebugMessageCallback = (PFNGLDEBUGMESSAGECALLBACKPROC) load(userptr, "glDebugMessageCallback");
+ context->DebugMessageControl = (PFNGLDEBUGMESSAGECONTROLPROC) load(userptr, "glDebugMessageControl");
+ context->DebugMessageInsert = (PFNGLDEBUGMESSAGEINSERTPROC) load(userptr, "glDebugMessageInsert");
+ context->DispatchCompute = (PFNGLDISPATCHCOMPUTEPROC) load(userptr, "glDispatchCompute");
+ context->DispatchComputeIndirect = (PFNGLDISPATCHCOMPUTEINDIRECTPROC) load(userptr, "glDispatchComputeIndirect");
+ context->FramebufferParameteri = (PFNGLFRAMEBUFFERPARAMETERIPROC) load(userptr, "glFramebufferParameteri");
+ context->GetDebugMessageLog = (PFNGLGETDEBUGMESSAGELOGPROC) load(userptr, "glGetDebugMessageLog");
+ context->GetFramebufferParameteriv = (PFNGLGETFRAMEBUFFERPARAMETERIVPROC) load(userptr, "glGetFramebufferParameteriv");
+ context->GetInternalformati64v = (PFNGLGETINTERNALFORMATI64VPROC) load(userptr, "glGetInternalformati64v");
+ context->GetObjectLabel = (PFNGLGETOBJECTLABELPROC) load(userptr, "glGetObjectLabel");
+ context->GetObjectPtrLabel = (PFNGLGETOBJECTPTRLABELPROC) load(userptr, "glGetObjectPtrLabel");
+ context->GetPointerv = (PFNGLGETPOINTERVPROC) load(userptr, "glGetPointerv");
+ context->GetProgramInterfaceiv = (PFNGLGETPROGRAMINTERFACEIVPROC) load(userptr, "glGetProgramInterfaceiv");
+ context->GetProgramResourceIndex = (PFNGLGETPROGRAMRESOURCEINDEXPROC) load(userptr, "glGetProgramResourceIndex");
+ context->GetProgramResourceLocation = (PFNGLGETPROGRAMRESOURCELOCATIONPROC) load(userptr, "glGetProgramResourceLocation");
+ context->GetProgramResourceLocationIndex = (PFNGLGETPROGRAMRESOURCELOCATIONINDEXPROC) load(userptr, "glGetProgramResourceLocationIndex");
+ context->GetProgramResourceName = (PFNGLGETPROGRAMRESOURCENAMEPROC) load(userptr, "glGetProgramResourceName");
+ context->GetProgramResourceiv = (PFNGLGETPROGRAMRESOURCEIVPROC) load(userptr, "glGetProgramResourceiv");
+ context->InvalidateBufferData = (PFNGLINVALIDATEBUFFERDATAPROC) load(userptr, "glInvalidateBufferData");
+ context->InvalidateBufferSubData = (PFNGLINVALIDATEBUFFERSUBDATAPROC) load(userptr, "glInvalidateBufferSubData");
+ context->InvalidateFramebuffer = (PFNGLINVALIDATEFRAMEBUFFERPROC) load(userptr, "glInvalidateFramebuffer");
+ context->InvalidateSubFramebuffer = (PFNGLINVALIDATESUBFRAMEBUFFERPROC) load(userptr, "glInvalidateSubFramebuffer");
+ context->InvalidateTexImage = (PFNGLINVALIDATETEXIMAGEPROC) load(userptr, "glInvalidateTexImage");
+ context->InvalidateTexSubImage = (PFNGLINVALIDATETEXSUBIMAGEPROC) load(userptr, "glInvalidateTexSubImage");
+ context->MultiDrawArraysIndirect = (PFNGLMULTIDRAWARRAYSINDIRECTPROC) load(userptr, "glMultiDrawArraysIndirect");
+ context->MultiDrawElementsIndirect = (PFNGLMULTIDRAWELEMENTSINDIRECTPROC) load(userptr, "glMultiDrawElementsIndirect");
+ context->ObjectLabel = (PFNGLOBJECTLABELPROC) load(userptr, "glObjectLabel");
+ context->ObjectPtrLabel = (PFNGLOBJECTPTRLABELPROC) load(userptr, "glObjectPtrLabel");
+ context->PopDebugGroup = (PFNGLPOPDEBUGGROUPPROC) load(userptr, "glPopDebugGroup");
+ context->PushDebugGroup = (PFNGLPUSHDEBUGGROUPPROC) load(userptr, "glPushDebugGroup");
+ context->ShaderStorageBlockBinding = (PFNGLSHADERSTORAGEBLOCKBINDINGPROC) load(userptr, "glShaderStorageBlockBinding");
+ context->TexBufferRange = (PFNGLTEXBUFFERRANGEPROC) load(userptr, "glTexBufferRange");
+ context->TexStorage2DMultisample = (PFNGLTEXSTORAGE2DMULTISAMPLEPROC) load(userptr, "glTexStorage2DMultisample");
+ context->TexStorage3DMultisample = (PFNGLTEXSTORAGE3DMULTISAMPLEPROC) load(userptr, "glTexStorage3DMultisample");
+ context->TextureView = (PFNGLTEXTUREVIEWPROC) load(userptr, "glTextureView");
+ context->VertexAttribBinding = (PFNGLVERTEXATTRIBBINDINGPROC) load(userptr, "glVertexAttribBinding");
+ context->VertexAttribFormat = (PFNGLVERTEXATTRIBFORMATPROC) load(userptr, "glVertexAttribFormat");
+ context->VertexAttribIFormat = (PFNGLVERTEXATTRIBIFORMATPROC) load(userptr, "glVertexAttribIFormat");
+ context->VertexAttribLFormat = (PFNGLVERTEXATTRIBLFORMATPROC) load(userptr, "glVertexAttribLFormat");
+ context->VertexBindingDivisor = (PFNGLVERTEXBINDINGDIVISORPROC) load(userptr, "glVertexBindingDivisor");
+}
-#if defined(GL_ES_VERSION_3_0) || defined(GL_VERSION_3_0)
-#define GLAD_GL_IS_SOME_NEW_VERSION 1
-#else
-#define GLAD_GL_IS_SOME_NEW_VERSION 0
-#endif
-
-static int glad_gl_get_extensions(GladGLContext *context, int version, const char **out_exts, unsigned int *out_num_exts_i, char ***out_exts_i) {
-#if GLAD_GL_IS_SOME_NEW_VERSION
- if(GLAD_VERSION_MAJOR(version) < 3) {
-#else
- GLAD_UNUSED(version);
- GLAD_UNUSED(out_num_exts_i);
- GLAD_UNUSED(out_exts_i);
-#endif
- if (context->GetString == NULL) {
- return 0;
+static void glad_gl_free_extensions(char **exts_i) {
+ if (exts_i != NULL) {
+ unsigned int index;
+ for(index = 0; exts_i[index]; index++) {
+ free((void *) (exts_i[index]));
}
- *out_exts = (const char *)context->GetString(GL_EXTENSIONS);
-#if GLAD_GL_IS_SOME_NEW_VERSION
- } else {
+ free((void *)exts_i);
+ exts_i = NULL;
+ }
+}
+static int glad_gl_get_extensions(GladGLContext *context, const char **out_exts, char ***out_exts_i) {
+#if defined(GL_ES_VERSION_3_0) || defined(GL_VERSION_3_0)
+ if (context->GetStringi != NULL && context->GetIntegerv != NULL) {
unsigned int index = 0;
unsigned int num_exts_i = 0;
char **exts_i = NULL;
- if (context->GetStringi == NULL || context->GetIntegerv == NULL) {
- return 0;
- }
context->GetIntegerv(GL_NUM_EXTENSIONS, (int*) &num_exts_i);
- if (num_exts_i > 0) {
- exts_i = (char **) malloc(num_exts_i * (sizeof *exts_i));
- }
+ exts_i = (char **) malloc((num_exts_i + 1) * (sizeof *exts_i));
if (exts_i == NULL) {
return 0;
}
@@ -452,31 +643,40 @@ static int glad_gl_get_extensions(GladGLContext *context, int version, const cha
size_t len = strlen(gl_str_tmp) + 1;
char *local_str = (char*) malloc(len * sizeof(char));
- if(local_str != NULL) {
- memcpy(local_str, gl_str_tmp, len * sizeof(char));
+ if(local_str == NULL) {
+ exts_i[index] = NULL;
+ glad_gl_free_extensions(exts_i);
+ return 0;
}
+ memcpy(local_str, gl_str_tmp, len * sizeof(char));
exts_i[index] = local_str;
}
+ exts_i[index] = NULL;
- *out_num_exts_i = num_exts_i;
*out_exts_i = exts_i;
+
+ return 1;
}
+#else
+ GLAD_UNUSED(out_exts_i);
#endif
+ if (context->GetString == NULL) {
+ return 0;
+ }
+ *out_exts = (const char *)context->GetString(GL_EXTENSIONS);
return 1;
}
-static void glad_gl_free_extensions(char **exts_i, unsigned int num_exts_i) {
- if (exts_i != NULL) {
+static int glad_gl_has_extension(const char *exts, char **exts_i, const char *ext) {
+ if(exts_i) {
unsigned int index;
- for(index = 0; index < num_exts_i; index++) {
- free((void *) (exts_i[index]));
+ for(index = 0; exts_i[index]; index++) {
+ const char *e = exts_i[index];
+ if(strcmp(e, ext) == 0) {
+ return 1;
+ }
}
- free((void *)exts_i);
- exts_i = NULL;
- }
-}
-static int glad_gl_has_extension(int version, const char *exts, unsigned int num_exts_i, char **exts_i, const char *ext) {
- if(GLAD_VERSION_MAJOR(version) < 3 || !GLAD_GL_IS_SOME_NEW_VERSION) {
+ } else {
const char *extensions;
const char *loc;
const char *terminator;
@@ -496,14 +696,6 @@ static int glad_gl_has_extension(int version, const char *exts, unsigned int num
}
extensions = terminator;
}
- } else {
- unsigned int index;
- for(index = 0; index < num_exts_i; index++) {
- const char *e = exts_i[index];
- if(strcmp(e, ext) == 0) {
- return 1;
- }
- }
}
return 0;
}
@@ -512,15 +704,14 @@ static GLADapiproc glad_gl_get_proc_from_userptr(void *userptr, const char* name
return (GLAD_GNUC_EXTENSION (GLADapiproc (*)(const char *name)) userptr)(name);
}
-static int glad_gl_find_extensions_gl(GladGLContext *context, int version) {
+static int glad_gl_find_extensions_gl(GladGLContext *context) {
const char *exts = NULL;
- unsigned int num_exts_i = 0;
char **exts_i = NULL;
- if (!glad_gl_get_extensions(context, version, &exts, &num_exts_i, &exts_i)) return 0;
+ if (!glad_gl_get_extensions(context, &exts, &exts_i)) return 0;
- GLAD_UNUSED(glad_gl_has_extension);
+ GLAD_UNUSED(&glad_gl_has_extension);
- glad_gl_free_extensions(exts_i, num_exts_i);
+ glad_gl_free_extensions(exts_i);
return 1;
}
@@ -561,6 +752,10 @@ static int glad_gl_find_core_gl(GladGLContext *context) {
context->VERSION_3_1 = (major == 3 && minor >= 1) || major > 3;
context->VERSION_3_2 = (major == 3 && minor >= 2) || major > 3;
context->VERSION_3_3 = (major == 3 && minor >= 3) || major > 3;
+ context->VERSION_4_0 = (major == 4 && minor >= 0) || major > 4;
+ context->VERSION_4_1 = (major == 4 && minor >= 1) || major > 4;
+ context->VERSION_4_2 = (major == 4 && minor >= 2) || major > 4;
+ context->VERSION_4_3 = (major == 4 && minor >= 3) || major > 4;
return GLAD_MAKE_VERSION(major, minor);
}
@@ -570,7 +765,6 @@ int gladLoadGLContextUserPtr(GladGLContext *context, GLADuserptrloadfunc load, v
context->GetString = (PFNGLGETSTRINGPROC) load(userptr, "glGetString");
if(context->GetString == NULL) return 0;
- if(context->GetString(GL_VERSION) == NULL) return 0;
version = glad_gl_find_core_gl(context);
glad_gl_load_GL_VERSION_1_0(context, load, userptr);
@@ -585,8 +779,12 @@ int gladLoadGLContextUserPtr(GladGLContext *context, GLADuserptrloadfunc load, v
glad_gl_load_GL_VERSION_3_1(context, load, userptr);
glad_gl_load_GL_VERSION_3_2(context, load, userptr);
glad_gl_load_GL_VERSION_3_3(context, load, userptr);
+ glad_gl_load_GL_VERSION_4_0(context, load, userptr);
+ glad_gl_load_GL_VERSION_4_1(context, load, userptr);
+ glad_gl_load_GL_VERSION_4_2(context, load, userptr);
+ glad_gl_load_GL_VERSION_4_3(context, load, userptr);
- if (!glad_gl_find_extensions_gl(context, version)) return 0;
+ if (!glad_gl_find_extensions_gl(context)) return 0;