From 425c86179791cd32e2d4ce0ae7f269940b4b0663 Mon Sep 17 00:00:00 2001 From: Can Celik Date: Thu, 10 Sep 2026 21:51:57 +0300 Subject: chore: upgrade libghostty and preserve terminal behavior (#3906) --- vendor/libghostty-vt/src/App.zig | 67 +- vendor/libghostty-vt/src/Command.zig | 307 +- vendor/libghostty-vt/src/Surface.zig | 1073 ++-- vendor/libghostty-vt/src/apprt.zig | 10 + vendor/libghostty-vt/src/apprt/action.zig | 99 +- vendor/libghostty-vt/src/apprt/embedded.zig | 475 +- vendor/libghostty-vt/src/apprt/gtk/App.zig | 2 + vendor/libghostty-vt/src/apprt/gtk/Surface.zig | 4 +- vendor/libghostty-vt/src/apprt/gtk/adw_version.zig | 26 +- .../src/apprt/gtk/build/blueprint.zig | 94 +- .../src/apprt/gtk/build/gresource.zig | 33 +- vendor/libghostty-vt/src/apprt/gtk/class.zig | 28 +- .../src/apprt/gtk/class/application.zig | 756 ++- .../gtk/class/clipboard_confirmation_dialog.zig | 5 +- .../src/apprt/gtk/class/command_palette.zig | 10 +- .../src/apprt/gtk/class/global_shortcuts.zig | 71 +- .../src/apprt/gtk/class/imgui_widget.zig | 19 +- .../src/apprt/gtk/class/split_tree.zig | 291 +- .../libghostty-vt/src/apprt/gtk/class/surface.zig | 524 +- .../src/apprt/gtk/class/surface_child_exited.zig | 4 +- .../apprt/gtk/class/surface_scrolled_window.zig | 12 +- vendor/libghostty-vt/src/apprt/gtk/class/tab.zig | 4 +- .../src/apprt/gtk/class/title_dialog.zig | 3 + .../libghostty-vt/src/apprt/gtk/class/window.zig | 147 +- vendor/libghostty-vt/src/apprt/gtk/css/style.css | 54 +- vendor/libghostty-vt/src/apprt/gtk/ext/actions.zig | 6 +- vendor/libghostty-vt/src/apprt/gtk/gtk_version.zig | 42 +- vendor/libghostty-vt/src/apprt/gtk/ipc/DBus.zig | 9 +- vendor/libghostty-vt/src/apprt/gtk/ipc/new_tab.zig | 71 + vendor/libghostty-vt/src/apprt/gtk/key.zig | 120 + vendor/libghostty-vt/src/apprt/gtk/media.zig | 7 +- vendor/libghostty-vt/src/apprt/gtk/portal.zig | 4 +- .../libghostty-vt/src/apprt/gtk/portal/OpenURI.zig | 43 +- vendor/libghostty-vt/src/apprt/gtk/post_fork.zig | 9 +- vendor/libghostty-vt/src/apprt/gtk/pre_exec.zig | 9 +- .../src/apprt/gtk/ui/1.2/config-errors-dialog.blp | 4 +- .../src/apprt/gtk/ui/1.2/search-overlay.blp | 7 +- .../libghostty-vt/src/apprt/gtk/ui/1.2/surface.blp | 72 +- vendor/libghostty-vt/src/apprt/gtk/ui/1.5/tab.blp | 9 +- .../libghostty-vt/src/apprt/gtk/ui/1.5/window.blp | 37 +- vendor/libghostty-vt/src/apprt/gtk/weak_ref.zig | 17 + vendor/libghostty-vt/src/apprt/gtk/winproto.zig | 23 +- .../libghostty-vt/src/apprt/gtk/winproto/noop.zig | 13 +- .../src/apprt/gtk/winproto/wayland.zig | 35 +- .../src/apprt/gtk/winproto/wayland/Globals.zig | 3 + .../src/apprt/gtk/winproto/wayland/Hotkeys.zig | 165 + .../wayland/protocols/vicinae-hotkey-v1.xml | 305 ++ .../libghostty-vt/src/apprt/gtk/winproto/x11.zig | 13 +- vendor/libghostty-vt/src/apprt/ipc.zig | 100 +- vendor/libghostty-vt/src/apprt/structs.zig | 133 +- vendor/libghostty-vt/src/apprt/surface.zig | 134 +- vendor/libghostty-vt/src/benchmark/ApcParser.zig | 20 +- vendor/libghostty-vt/src/benchmark/Benchmark.zig | 11 +- vendor/libghostty-vt/src/benchmark/CApi.zig | 4 +- .../libghostty-vt/src/benchmark/CodepointWidth.zig | 11 +- .../libghostty-vt/src/benchmark/GraphemeBreak.zig | 9 +- vendor/libghostty-vt/src/benchmark/IsSymbol.zig | 9 +- vendor/libghostty-vt/src/benchmark/OscParser.zig | 7 +- .../src/benchmark/PageCompression.zig | 6 +- vendor/libghostty-vt/src/benchmark/ScreenClone.zig | 8 +- .../src/benchmark/ScrollbackCompression.zig | 9 +- .../src/benchmark/TerminalFormatter.zig | 482 ++ .../libghostty-vt/src/benchmark/TerminalParser.zig | 7 +- .../libghostty-vt/src/benchmark/TerminalResize.zig | 357 ++ .../src/benchmark/TerminalSnapshot.zig | 350 ++ .../libghostty-vt/src/benchmark/TerminalStream.zig | 43 +- vendor/libghostty-vt/src/benchmark/cli.zig | 24 +- vendor/libghostty-vt/src/benchmark/main.zig | 3 + vendor/libghostty-vt/src/benchmark/options.zig | 7 +- vendor/libghostty-vt/src/build/Config.zig | 251 +- vendor/libghostty-vt/src/build/GhosttyBench.zig | 4 +- vendor/libghostty-vt/src/build/GhosttyDist.zig | 4 +- vendor/libghostty-vt/src/build/GhosttyDocs.zig | 6 +- vendor/libghostty-vt/src/build/GhosttyExe.zig | 17 +- .../libghostty-vt/src/build/GhosttyFrameData.zig | 8 +- vendor/libghostty-vt/src/build/GhosttyI18n.zig | 21 +- vendor/libghostty-vt/src/build/GhosttyLib.zig | 38 +- vendor/libghostty-vt/src/build/GhosttyLibVt.zig | 217 +- .../libghostty-vt/src/build/GhosttyResources.zig | 30 +- vendor/libghostty-vt/src/build/GhosttyWebdata.zig | 6 +- .../libghostty-vt/src/build/GhosttyXCFramework.zig | 39 - .../libghostty-vt/src/build/GhosttyXcodebuild.zig | 10 +- vendor/libghostty-vt/src/build/GhosttyZig.zig | 18 +- vendor/libghostty-vt/src/build/GitVersion.zig | 14 +- vendor/libghostty-vt/src/build/HelpStrings.zig | 2 +- .../src/build/LibsystemOverrideStep.zig | 51 + vendor/libghostty-vt/src/build/MetallibStep.zig | 11 - vendor/libghostty-vt/src/build/SharedDeps.zig | 299 +- vendor/libghostty-vt/src/build/UnicodeTables.zig | 4 +- vendor/libghostty-vt/src/build/XCFrameworkStep.zig | 4 +- .../libghostty-vt/src/build/combine_archives.zig | 28 +- .../src/build/docker/lib-c-docs/Dockerfile | 1 + vendor/libghostty-vt/src/build/gtk.zig | 2 +- .../libghostty-vt/src/build/libsystem_override.sh | 123 + .../src/build/mdgen/main_ghostty_1.zig | 7 +- .../src/build/mdgen/main_ghostty_5.zig | 7 +- vendor/libghostty-vt/src/build/uucode_config.zig | 227 +- .../src/build/wasm_patch_growable_table.zig | 21 +- .../src/build/webgen/main_actions.zig | 4 +- .../src/build/webgen/main_commands.zig | 4 +- .../libghostty-vt/src/build/webgen/main_config.zig | 4 +- vendor/libghostty-vt/src/cli/Pager.zig | 51 +- vendor/libghostty-vt/src/cli/action.zig | 30 +- vendor/libghostty-vt/src/cli/args.zig | 79 +- vendor/libghostty-vt/src/cli/boo.zig | 8 +- vendor/libghostty-vt/src/cli/crash_report.zig | 25 +- vendor/libghostty-vt/src/cli/diagnostics.zig | 4 +- vendor/libghostty-vt/src/cli/edit_config.zig | 132 +- vendor/libghostty-vt/src/cli/explain_config.zig | 9 +- vendor/libghostty-vt/src/cli/ghostty.zig | 30 +- vendor/libghostty-vt/src/cli/help.zig | 7 +- vendor/libghostty-vt/src/cli/list_actions.zig | 7 +- vendor/libghostty-vt/src/cli/list_colors.zig | 19 +- vendor/libghostty-vt/src/cli/list_fonts.zig | 9 +- vendor/libghostty-vt/src/cli/list_keybinds.zig | 18 +- vendor/libghostty-vt/src/cli/list_themes.zig | 215 +- vendor/libghostty-vt/src/cli/new_tab.zig | 277 ++ vendor/libghostty-vt/src/cli/new_window.zig | 68 +- vendor/libghostty-vt/src/cli/show_config.zig | 5 +- vendor/libghostty-vt/src/cli/show_face.zig | 7 +- .../libghostty-vt/src/cli/ssh-cache/DiskCache.zig | 226 +- vendor/libghostty-vt/src/cli/ssh.zig | 138 +- vendor/libghostty-vt/src/cli/ssh_cache.zig | 132 +- .../src/cli/toggle_quick_terminal.zig | 3 +- vendor/libghostty-vt/src/cli/validate_config.zig | 11 +- vendor/libghostty-vt/src/cli/version.zig | 12 +- vendor/libghostty-vt/src/config/CApi.zig | 26 +- vendor/libghostty-vt/src/config/Config.zig | 692 ++- vendor/libghostty-vt/src/config/c_get.zig | 2 +- vendor/libghostty-vt/src/config/command.zig | 2 +- vendor/libghostty-vt/src/config/conditional.zig | 20 +- vendor/libghostty-vt/src/config/edit.zig | 76 +- vendor/libghostty-vt/src/config/file_load.zig | 42 +- vendor/libghostty-vt/src/config/formatter_file.zig | 2 +- vendor/libghostty-vt/src/config/io.zig | 2 +- vendor/libghostty-vt/src/config/key.zig | 27 +- vendor/libghostty-vt/src/config/limit.zig | 115 + vendor/libghostty-vt/src/config/path.zig | 58 +- vendor/libghostty-vt/src/config/string.zig | 24 +- vendor/libghostty-vt/src/config/theme.zig | 28 +- vendor/libghostty-vt/src/config/url.zig | 16 +- vendor/libghostty-vt/src/crash/dir.zig | 28 +- vendor/libghostty-vt/src/crash/sentry.zig | 164 +- vendor/libghostty-vt/src/crash/sentry_envelope.zig | 6 +- vendor/libghostty-vt/src/crc32c.zig | 424 ++ .../src/datastruct/array_list_collection.zig | 44 - .../src/datastruct/blocking_queue.zig | 83 +- vendor/libghostty-vt/src/datastruct/circ_buf.zig | 67 +- vendor/libghostty-vt/src/datastruct/comparison.zig | 6 +- .../src/datastruct/limited_allocator.zig | 124 + vendor/libghostty-vt/src/datastruct/lru.zig | 349 -- vendor/libghostty-vt/src/datastruct/main.zig | 6 +- .../src/datastruct/segmented_list.zig | 533 ++ .../src/datastruct/segmented_pool.zig | 95 - vendor/libghostty-vt/src/datastruct/split_tree.zig | 12 + .../src/datastruct/untouched_pool.zig | 297 ++ .../src/datastruct/wasm_page_pool.zig | 162 + vendor/libghostty-vt/src/fastprint.zig | 68 + vendor/libghostty-vt/src/font/Atlas.zig | 4 +- vendor/libghostty-vt/src/font/Collection.zig | 7 +- vendor/libghostty-vt/src/font/DeferredFace.zig | 3 +- vendor/libghostty-vt/src/font/Metrics.zig | 22 +- vendor/libghostty-vt/src/font/SharedGrid.zig | 160 +- vendor/libghostty-vt/src/font/SharedGridSet.zig | 33 +- vendor/libghostty-vt/src/font/discovery.zig | 85 +- vendor/libghostty-vt/src/font/face/coretext.zig | 1 + vendor/libghostty-vt/src/font/face/freetype.zig | 45 +- vendor/libghostty-vt/src/font/glyf_rasterize.zig | 12 +- .../src/font/glyf_rasterize_png_test.zig | 28 +- vendor/libghostty-vt/src/font/library.zig | 6 +- vendor/libghostty-vt/src/font/opentype/glyf.zig | 60 +- vendor/libghostty-vt/src/font/opentype/head.zig | 8 +- vendor/libghostty-vt/src/font/opentype/hhea.zig | 7 +- vendor/libghostty-vt/src/font/opentype/os2.zig | 21 +- vendor/libghostty-vt/src/font/opentype/post.zig | 8 +- vendor/libghostty-vt/src/font/opentype/sfnt.zig | 7 +- vendor/libghostty-vt/src/font/opentype/svg.zig | 37 +- vendor/libghostty-vt/src/font/shaper/coretext.zig | 93 +- vendor/libghostty-vt/src/font/shaper/feature.zig | 33 +- vendor/libghostty-vt/src/font/shaper/harfbuzz.zig | 128 +- vendor/libghostty-vt/src/font/shaper/run.zig | 53 +- vendor/libghostty-vt/src/font/sprite/Face.zig | 32 +- vendor/libghostty-vt/src/font/sprite/canvas.zig | 83 +- .../src/font/sprite/draw/powerline.zig | 10 +- .../sprite/testdata/U+1CC00...U+1CCFF-11x21+2.png | Bin 1032 -> 941 bytes .../sprite/testdata/U+1CC00...U+1CCFF-12x24+3.png | Bin 1295 -> 1151 bytes .../sprite/testdata/U+1CC00...U+1CCFF-18x36+4.png | Bin 2193 -> 1994 bytes .../sprite/testdata/U+1CC00...U+1CCFF-9x17+1.png | Bin 794 -> 717 bytes .../sprite/testdata/U+1CD00...U+1CDFF-11x21+2.png | Bin 1280 -> 2146 bytes .../sprite/testdata/U+1CD00...U+1CDFF-12x24+3.png | Bin 1870 -> 2529 bytes .../sprite/testdata/U+1CD00...U+1CDFF-18x36+4.png | Bin 3411 -> 4398 bytes .../sprite/testdata/U+1CD00...U+1CDFF-9x17+1.png | Bin 1103 -> 1625 bytes .../sprite/testdata/U+1CE00...U+1CEFF-11x21+2.png | Bin 1006 -> 881 bytes .../sprite/testdata/U+1CE00...U+1CEFF-12x24+3.png | Bin 1255 -> 1082 bytes .../sprite/testdata/U+1CE00...U+1CEFF-18x36+4.png | Bin 2247 -> 2371 bytes .../sprite/testdata/U+1CE00...U+1CEFF-9x17+1.png | Bin 751 -> 693 bytes .../sprite/testdata/U+1FB00...U+1FBFF-11x21+2.png | Bin 5427 -> 5871 bytes .../sprite/testdata/U+1FB00...U+1FBFF-12x24+3.png | Bin 5724 -> 6354 bytes .../sprite/testdata/U+1FB00...U+1FBFF-18x36+4.png | Bin 9975 -> 11167 bytes .../sprite/testdata/U+1FB00...U+1FBFF-9x17+1.png | Bin 4341 -> 4598 bytes .../sprite/testdata/U+2500...U+25FF-11x21+2.png | Bin 2225 -> 2412 bytes .../sprite/testdata/U+2500...U+25FF-12x24+3.png | Bin 2635 -> 2839 bytes .../sprite/testdata/U+2500...U+25FF-18x36+4.png | Bin 4570 -> 5323 bytes .../sprite/testdata/U+2500...U+25FF-9x17+1.png | Bin 1853 -> 1792 bytes .../sprite/testdata/U+2800...U+28FF-11x21+2.png | Bin 1022 -> 1010 bytes .../sprite/testdata/U+2800...U+28FF-12x24+3.png | Bin 1541 -> 1276 bytes .../sprite/testdata/U+2800...U+28FF-18x36+4.png | Bin 2501 -> 1993 bytes .../sprite/testdata/U+2800...U+28FF-9x17+1.png | Bin 917 -> 953 bytes .../sprite/testdata/U+E000...U+E0FF-11x21+2.png | Bin 1102 -> 1030 bytes .../sprite/testdata/U+E000...U+E0FF-12x24+3.png | Bin 1255 -> 1144 bytes .../sprite/testdata/U+E000...U+E0FF-18x36+4.png | Bin 2217 -> 1978 bytes .../sprite/testdata/U+E000...U+E0FF-9x17+1.png | Bin 894 -> 834 bytes .../sprite/testdata/U+F500...U+F5FF-11x21+2.png | Bin 1114 -> 1044 bytes .../sprite/testdata/U+F500...U+F5FF-12x24+3.png | Bin 1421 -> 1279 bytes .../sprite/testdata/U+F500...U+F5FF-18x36+4.png | Bin 2473 -> 2073 bytes .../sprite/testdata/U+F500...U+F5FF-9x17+1.png | Bin 872 -> 831 bytes .../sprite/testdata/U+F600...U+F6FF-11x21+2.png | Bin 495 -> 359 bytes .../sprite/testdata/U+F600...U+F6FF-12x24+3.png | Bin 637 -> 438 bytes .../sprite/testdata/U+F600...U+F6FF-18x36+4.png | Bin 1210 -> 752 bytes .../sprite/testdata/U+F600...U+F6FF-9x17+1.png | Bin 393 -> 288 bytes .../src/font/testdata/glyf_rasterize.png | Bin 1215 -> 1291 bytes vendor/libghostty-vt/src/global.zig | 513 +- vendor/libghostty-vt/src/helpgen.zig | 8 +- vendor/libghostty-vt/src/input/Binding.zig | 94 +- vendor/libghostty-vt/src/input/command.zig | 387 +- vendor/libghostty-vt/src/input/function_keys.zig | 58 +- vendor/libghostty-vt/src/input/key.zig | 10 +- vendor/libghostty-vt/src/input/key_encode.zig | 428 +- vendor/libghostty-vt/src/input/key_mods.zig | 9 +- vendor/libghostty-vt/src/input/paste.zig | 168 +- vendor/libghostty-vt/src/inspector/Inspector.zig | 2 - vendor/libghostty-vt/src/inspector/widgets.zig | 89 - vendor/libghostty-vt/src/inspector/widgets/key.zig | 14 +- .../src/inspector/widgets/pagelist.zig | 2 +- .../libghostty-vt/src/inspector/widgets/screen.zig | 7 +- .../src/inspector/widgets/surface.zig | 13 +- .../libghostty-vt/src/inspector/widgets/termio.zig | 89 +- vendor/libghostty-vt/src/lib/TinyIo.zig | 269 + vendor/libghostty-vt/src/lib/allocator.zig | 8 +- .../src/lib/allocator/convenience.zig | 59 - vendor/libghostty-vt/src/lib/allocator/wasm.zig | 98 + vendor/libghostty-vt/src/lib/c_abi.zig | 28 + vendor/libghostty-vt/src/lib/compat/README.md | 37 + vendor/libghostty-vt/src/lib/compat/fd.zig | 83 + vendor/libghostty-vt/src/lib/compat/file.zig | 56 + vendor/libghostty-vt/src/lib/compat/reader.zig | 64 + vendor/libghostty-vt/src/lib/compat/testing.zig | 19 + vendor/libghostty-vt/src/lib/compat/thread.zig | 75 + vendor/libghostty-vt/src/lib/enum.zig | 99 +- vendor/libghostty-vt/src/lib/main.zig | 5 +- vendor/libghostty-vt/src/lib/packed.zig | 290 ++ vendor/libghostty-vt/src/lib/string.zig | 15 - vendor/libghostty-vt/src/lib/struct.zig | 24 +- vendor/libghostty-vt/src/lib/tinyio/posix.zig | 608 +++ vendor/libghostty-vt/src/lib/tinyio/test.zig | 836 ++++ vendor/libghostty-vt/src/lib/tinyio/windows.zig | 745 +++ vendor/libghostty-vt/src/lib/types.zig | 17 +- vendor/libghostty-vt/src/lib/union.zig | 126 +- vendor/libghostty-vt/src/lib_vt.zig | 531 +- vendor/libghostty-vt/src/main.zig | 12 + vendor/libghostty-vt/src/main_bench.zig | 6 + vendor/libghostty-vt/src/main_build_data.zig | 6 +- vendor/libghostty-vt/src/main_c.zig | 52 +- vendor/libghostty-vt/src/main_gen.zig | 6 + vendor/libghostty-vt/src/main_ghostty.zig | 95 +- vendor/libghostty-vt/src/os/TempDir.zig | 83 +- vendor/libghostty-vt/src/os/args.zig | 130 - vendor/libghostty-vt/src/os/cf_release_thread.zig | 7 +- vendor/libghostty-vt/src/os/cgroup.zig | 9 +- vendor/libghostty-vt/src/os/desktop.zig | 97 +- vendor/libghostty-vt/src/os/edit.zig | 69 + vendor/libghostty-vt/src/os/env.zig | 178 - vendor/libghostty-vt/src/os/file.zig | 11 +- vendor/libghostty-vt/src/os/flatpak.zig | 173 +- vendor/libghostty-vt/src/os/homedir.zig | 92 +- vendor/libghostty-vt/src/os/hostname.zig | 90 +- vendor/libghostty-vt/src/os/i18n.zig | 17 + vendor/libghostty-vt/src/os/i18n_locales.zig | 3 + vendor/libghostty-vt/src/os/kernel_info.zig | 13 +- vendor/libghostty-vt/src/os/locale.zig | 59 +- vendor/libghostty-vt/src/os/mach.zig | 9 +- vendor/libghostty-vt/src/os/macos.zig | 17 +- vendor/libghostty-vt/src/os/main.zig | 19 +- vendor/libghostty-vt/src/os/open.zig | 100 +- vendor/libghostty-vt/src/os/passwd.zig | 7 +- vendor/libghostty-vt/src/os/path.zig | 51 +- vendor/libghostty-vt/src/os/pipe.zig | 7 +- vendor/libghostty-vt/src/os/resourcesdir.zig | 35 +- vendor/libghostty-vt/src/os/stderr.zig | 60 + vendor/libghostty-vt/src/os/systemd.zig | 28 +- vendor/libghostty-vt/src/os/wasm/log.zig | 16 + vendor/libghostty-vt/src/os/windows.zig | 449 +- vendor/libghostty-vt/src/os/xdg.zig | 127 +- vendor/libghostty-vt/src/pty.zig | 79 +- vendor/libghostty-vt/src/quirks_memset.zig | 338 ++ vendor/libghostty-vt/src/renderer/Metal.zig | 38 + vendor/libghostty-vt/src/renderer/OpenGL.zig | 6 + vendor/libghostty-vt/src/renderer/Overlay.zig | 5 +- vendor/libghostty-vt/src/renderer/State.zig | 21 +- vendor/libghostty-vt/src/renderer/Thread.zig | 210 +- vendor/libghostty-vt/src/renderer/cell.zig | 156 +- vendor/libghostty-vt/src/renderer/cursor.zig | 12 +- vendor/libghostty-vt/src/renderer/generic.zig | 540 +- vendor/libghostty-vt/src/renderer/image.zig | 733 ++- vendor/libghostty-vt/src/renderer/link.zig | 9 +- .../src/renderer/metal/IOSurfaceLayer.zig | 4 + vendor/libghostty-vt/src/renderer/metal/api.zig | 9 +- vendor/libghostty-vt/src/renderer/metal/buffer.zig | 8 +- .../libghostty-vt/src/renderer/metal/shaders.zig | 30 +- .../libghostty-vt/src/renderer/opengl/shaders.zig | 24 +- vendor/libghostty-vt/src/renderer/shadertoy.zig | 12 +- .../src/shell-integration/bash/bash-preexec.sh | 342 +- vendor/libghostty-vt/src/simd/base64.zig | 363 +- vendor/libghostty-vt/src/simd/base64_scalar.zig | 6 + vendor/libghostty-vt/src/simd/main.zig | 12 + vendor/libghostty-vt/src/simd/vt.zig | 99 +- vendor/libghostty-vt/src/surface_mouse.zig | 85 +- vendor/libghostty-vt/src/synthetic/cli.zig | 29 +- vendor/libghostty-vt/src/synthetic/cli/Ascii.zig | 61 +- vendor/libghostty-vt/src/synthetic/cli/Styled.zig | 410 ++ vendor/libghostty-vt/src/synthetic/cli/Utf8.zig | 1 - vendor/libghostty-vt/src/terminal/PageList.zig | 5131 +++++++++++++++----- vendor/libghostty-vt/src/terminal/Parser.zig | 29 +- vendor/libghostty-vt/src/terminal/Screen.zig | 2108 ++++++-- vendor/libghostty-vt/src/terminal/ScreenSet.zig | 24 +- vendor/libghostty-vt/src/terminal/Selection.zig | 46 +- .../src/terminal/SelectionGesture.zig | 193 +- vendor/libghostty-vt/src/terminal/StringMap.zig | 38 +- vendor/libghostty-vt/src/terminal/Tabstops.zig | 61 +- vendor/libghostty-vt/src/terminal/Terminal.zig | 3650 +++++++++++--- vendor/libghostty-vt/src/terminal/ansi.zig | 13 +- vendor/libghostty-vt/src/terminal/apc.zig | 360 +- vendor/libghostty-vt/src/terminal/apc/glyph.zig | 7 +- .../src/terminal/apc/glyph/Glossary.zig | 73 +- .../src/terminal/apc/glyph/request.zig | 35 +- .../src/terminal/bitmap_allocator.zig | 233 +- .../libghostty-vt/src/terminal/build_options.zig | 271 +- vendor/libghostty-vt/src/terminal/c/allocator.zig | 5 +- vendor/libghostty-vt/src/terminal/c/build_info.zig | 2 +- vendor/libghostty-vt/src/terminal/c/cell.zig | 10 +- vendor/libghostty-vt/src/terminal/c/formatter.zig | 186 +- vendor/libghostty-vt/src/terminal/c/grid_ref.zig | 6 +- .../src/terminal/c/grid_ref_tracked.zig | 12 +- vendor/libghostty-vt/src/terminal/c/io.zig | 774 +++ vendor/libghostty-vt/src/terminal/c/key_encode.zig | 10 +- vendor/libghostty-vt/src/terminal/c/key_event.zig | 4 +- .../src/terminal/c/kitty_graphics.zig | 288 +- vendor/libghostty-vt/src/terminal/c/main.zig | 40 +- vendor/libghostty-vt/src/terminal/c/modes.zig | 4 +- .../libghostty-vt/src/terminal/c/mouse_encode.zig | 12 +- .../libghostty-vt/src/terminal/c/mouse_event.zig | 4 +- vendor/libghostty-vt/src/terminal/c/osc.zig | 7 +- vendor/libghostty-vt/src/terminal/c/paste.zig | 392 ++ vendor/libghostty-vt/src/terminal/c/render.zig | 929 +++- vendor/libghostty-vt/src/terminal/c/result.zig | 3 + vendor/libghostty-vt/src/terminal/c/row.zig | 2 +- vendor/libghostty-vt/src/terminal/c/search.zig | 1009 ++++ vendor/libghostty-vt/src/terminal/c/selection.zig | 61 +- .../src/terminal/c/selection_gesture.zig | 66 +- vendor/libghostty-vt/src/terminal/c/sgr.zig | 11 - vendor/libghostty-vt/src/terminal/c/snapshot.zig | 1527 ++++++ vendor/libghostty-vt/src/terminal/c/style.zig | 124 +- vendor/libghostty-vt/src/terminal/c/sys.zig | 136 +- vendor/libghostty-vt/src/terminal/c/terminal.zig | 3735 +++++++++++--- .../src/terminal/c/types-schema-verify.py | 114 + .../libghostty-vt/src/terminal/c/types.schema.json | 506 ++ vendor/libghostty-vt/src/terminal/c/types.zig | 1287 ++++- vendor/libghostty-vt/src/terminal/clipboard.zig | 244 +- vendor/libghostty-vt/src/terminal/color.zig | 265 +- .../libghostty-vt/src/terminal/compress/Page.zig | 14 + .../src/terminal/compress/lz4_differential.zig | 2 +- vendor/libghostty-vt/src/terminal/cursor.zig | 34 +- vendor/libghostty-vt/src/terminal/dcs.zig | 141 + .../libghostty-vt/src/terminal/device_status.zig | 60 +- vendor/libghostty-vt/src/terminal/formatter.zig | 1523 ++++-- vendor/libghostty-vt/src/terminal/fraction.zig | 142 + vendor/libghostty-vt/src/terminal/hash_map.zig | 315 +- vendor/libghostty-vt/src/terminal/hyperlink.zig | 4 +- vendor/libghostty-vt/src/terminal/kitty.zig | 2 + .../libghostty-vt/src/terminal/kitty/clipboard.zig | 82 + .../src/terminal/kitty/clipboard_command.zig | 502 ++ .../src/terminal/kitty/clipboard_grants.zig | 238 + .../src/terminal/kitty/clipboard_response.zig | 469 ++ .../src/terminal/kitty/clipboard_write.zig | 698 +++ vendor/libghostty-vt/src/terminal/kitty/dnd.zig | 69 + .../src/terminal/kitty/dnd_command.zig | 436 ++ .../libghostty-vt/src/terminal/kitty/dnd_drop.zig | 715 +++ .../src/terminal/kitty/dnd_response.zig | 248 + .../libghostty-vt/src/terminal/kitty/dnd_test.zig | 626 +++ .../libghostty-vt/src/terminal/kitty/graphics.zig | 8 +- .../src/terminal/kitty/graphics_animation.zig | 149 + .../src/terminal/kitty/graphics_command.zig | 914 +++- .../src/terminal/kitty/graphics_exec.zig | 3133 +++++++++++- .../src/terminal/kitty/graphics_image.zig | 1505 +++++- .../src/terminal/kitty/graphics_pixel.zig | 235 + .../src/terminal/kitty/graphics_storage.zig | 3883 +++++++++++++-- .../src/terminal/kitty/graphics_unicode.zig | 78 +- .../libghostty-vt/src/terminal/kitty/windows.zig | 277 ++ vendor/libghostty-vt/src/terminal/lib.zig | 1 + vendor/libghostty-vt/src/terminal/main.zig | 9 + vendor/libghostty-vt/src/terminal/mem.zig | 52 +- vendor/libghostty-vt/src/terminal/modes.zig | 196 +- vendor/libghostty-vt/src/terminal/mouse.zig | 3 +- vendor/libghostty-vt/src/terminal/osc.zig | 264 +- .../src/terminal/osc/kitty_metadata.zig | 94 + vendor/libghostty-vt/src/terminal/osc/parsers.zig | 1 + .../terminal/osc/parsers/clipboard_operation.zig | 20 +- .../src/terminal/osc/parsers/color.zig | 25 +- .../osc/parsers/kitty_clipboard_protocol.zig | 41 +- .../osc/parsers/kitty_desktop_notification.zig | 1201 +++++ .../terminal/osc/parsers/kitty_dnd_protocol.zig | 319 +- .../src/terminal/osc/parsers/kitty_text_sizing.zig | 2 +- .../src/terminal/osc/parsers/semantic_prompt.zig | 88 +- vendor/libghostty-vt/src/terminal/page.zig | 669 ++- vendor/libghostty-vt/src/terminal/parse_table.zig | 60 + vendor/libghostty-vt/src/terminal/paste.zig | 247 + vendor/libghostty-vt/src/terminal/point.zig | 10 +- .../libghostty-vt/src/terminal/ref_counted_set.zig | 209 +- vendor/libghostty-vt/src/terminal/render.zig | 268 +- vendor/libghostty-vt/src/terminal/search.zig | 1 + .../libghostty-vt/src/terminal/search/Thread.zig | 606 +-- .../libghostty-vt/src/terminal/search/active.zig | 6 +- .../libghostty-vt/src/terminal/search/pagelist.zig | 91 +- .../libghostty-vt/src/terminal/search/screen.zig | 264 +- .../src/terminal/search/sliding_window.zig | 79 +- .../libghostty-vt/src/terminal/search/terminal.zig | 767 +++ .../libghostty-vt/src/terminal/search/viewport.zig | 18 +- vendor/libghostty-vt/src/terminal/sgr.zig | 13 +- .../libghostty-vt/src/terminal/snapshot/AGENTS.md | 7 + .../src/terminal/snapshot/checkpoint.zig | 177 + .../src/terminal/snapshot/continuation.zig | 320 ++ .../src/terminal/snapshot/envelope.zig | 104 + .../src/terminal/snapshot/fixture.zig | 479 ++ .../libghostty-vt/src/terminal/snapshot/grid.zig | 2403 +++++++++ .../src/terminal/snapshot/history.zig | 716 +++ .../src/terminal/snapshot/hyperlink.zig | 493 ++ vendor/libghostty-vt/src/terminal/snapshot/io.zig | 55 + .../libghostty-vt/src/terminal/snapshot/main.zig | 182 + .../libghostty-vt/src/terminal/snapshot/page.zig | 1828 +++++++ .../libghostty-vt/src/terminal/snapshot/record.zig | 601 +++ .../libghostty-vt/src/terminal/snapshot/screen.zig | 2460 ++++++++++ .../src/terminal/snapshot/snapshot.ksy | 1073 ++++ .../src/terminal/snapshot/snapshot.zig | 2100 ++++++++ .../libghostty-vt/src/terminal/snapshot/style.zig | 423 ++ .../src/terminal/snapshot/terminal.zig | 1850 +++++++ .../snapshot/testdata/checkpoint-ready-v1.hex | 10 + .../src/terminal/snapshot/testdata/complete-v1.hex | 120 + .../snapshot/testdata/continuation-apc-v1.hex | 8 + .../snapshot/testdata/continuation-csi-v1.hex | 8 + .../snapshot/testdata/continuation-dcs-v1.hex | 8 + .../snapshot/testdata/continuation-esc-v1.hex | 8 + .../snapshot/testdata/continuation-ground-v1.hex | 8 + .../snapshot/testdata/continuation-osc-v1.hex | 8 + .../snapshot/testdata/continuation-utf8-v1.hex | 8 + .../src/terminal/snapshot/testdata/envelope-v1.hex | 10 + .../src/terminal/snapshot/testdata/grid-v1.hex | 15 + .../snapshot/testdata/history-header-v1.hex | 10 + .../snapshot/testdata/hyperlink-explicit-v1.hex | 10 + .../snapshot/testdata/hyperlink-implicit-v1.hex | 10 + .../snapshot/testdata/page-empty-record-v1.hex | 12 + .../terminal/snapshot/testdata/page-header-v1.hex | 11 + .../src/terminal/snapshot/testdata/page-v1.hex | 22 + .../snapshot/testdata/record-page-header-v1.hex | 10 + .../snapshot/testdata/screen-header-v1.hex | 13 + .../snapshot/testdata/screen-saved-cursor-v1.hex | 11 + .../src/terminal/snapshot/testdata/style-v1.hex | 10 + .../snapshot/testdata/terminal-header-v1.hex | 16 + .../src/terminal/snapshot/verify-kaitai.py | 400 ++ vendor/libghostty-vt/src/terminal/stream.zig | 1566 +++++- .../src/terminal/stream_continuation.zig | 606 +++ .../libghostty-vt/src/terminal/stream_terminal.zig | 4612 ++++++++++++++++-- vendor/libghostty-vt/src/terminal/style.zig | 222 +- vendor/libghostty-vt/src/terminal/sys.zig | 48 + vendor/libghostty-vt/src/terminal/tmux/control.zig | 2 +- vendor/libghostty-vt/src/terminal/tmux/output.zig | 24 +- vendor/libghostty-vt/src/terminal/tmux/viewer.zig | 31 +- vendor/libghostty-vt/src/terminfo/Source.zig | 38 +- vendor/libghostty-vt/src/terminfo/ghostty.zig | 13 + vendor/libghostty-vt/src/terminfo/main.zig | 1 + vendor/libghostty-vt/src/termio/Exec.zig | 320 +- vendor/libghostty-vt/src/termio/Termio.zig | 206 +- vendor/libghostty-vt/src/termio/Thread.zig | 24 +- vendor/libghostty-vt/src/termio/mailbox.zig | 15 +- vendor/libghostty-vt/src/termio/message.zig | 46 +- .../libghostty-vt/src/termio/shell_integration.zig | 110 +- vendor/libghostty-vt/src/termio/stream_handler.zig | 732 ++- vendor/libghostty-vt/src/tripwire.zig | 2 +- vendor/libghostty-vt/src/unicode/grapheme.zig | 24 +- vendor/libghostty-vt/src/unicode/props.zig | 14 +- vendor/libghostty-vt/src/unicode/props_uucode.zig | 8 +- .../libghostty-vt/src/unicode/symbols_uucode.zig | 8 +- 491 files changed, 83421 insertions(+), 13664 deletions(-) create mode 100644 vendor/libghostty-vt/src/apprt/gtk/ipc/new_tab.zig create mode 100644 vendor/libghostty-vt/src/apprt/gtk/winproto/wayland/Hotkeys.zig create mode 100644 vendor/libghostty-vt/src/apprt/gtk/winproto/wayland/protocols/vicinae-hotkey-v1.xml create mode 100644 vendor/libghostty-vt/src/benchmark/TerminalFormatter.zig create mode 100644 vendor/libghostty-vt/src/benchmark/TerminalResize.zig create mode 100644 vendor/libghostty-vt/src/benchmark/TerminalSnapshot.zig create mode 100644 vendor/libghostty-vt/src/build/LibsystemOverrideStep.zig create mode 100755 vendor/libghostty-vt/src/build/libsystem_override.sh create mode 100644 vendor/libghostty-vt/src/cli/new_tab.zig create mode 100644 vendor/libghostty-vt/src/config/limit.zig create mode 100644 vendor/libghostty-vt/src/crc32c.zig delete mode 100644 vendor/libghostty-vt/src/datastruct/array_list_collection.zig create mode 100644 vendor/libghostty-vt/src/datastruct/limited_allocator.zig delete mode 100644 vendor/libghostty-vt/src/datastruct/lru.zig create mode 100644 vendor/libghostty-vt/src/datastruct/segmented_list.zig delete mode 100644 vendor/libghostty-vt/src/datastruct/segmented_pool.zig create mode 100644 vendor/libghostty-vt/src/datastruct/untouched_pool.zig create mode 100644 vendor/libghostty-vt/src/datastruct/wasm_page_pool.zig create mode 100644 vendor/libghostty-vt/src/fastprint.zig create mode 100644 vendor/libghostty-vt/src/lib/TinyIo.zig delete mode 100644 vendor/libghostty-vt/src/lib/allocator/convenience.zig create mode 100644 vendor/libghostty-vt/src/lib/allocator/wasm.zig create mode 100644 vendor/libghostty-vt/src/lib/c_abi.zig create mode 100644 vendor/libghostty-vt/src/lib/compat/README.md create mode 100644 vendor/libghostty-vt/src/lib/compat/fd.zig create mode 100644 vendor/libghostty-vt/src/lib/compat/file.zig create mode 100644 vendor/libghostty-vt/src/lib/compat/reader.zig create mode 100644 vendor/libghostty-vt/src/lib/compat/testing.zig create mode 100644 vendor/libghostty-vt/src/lib/compat/thread.zig create mode 100644 vendor/libghostty-vt/src/lib/packed.zig delete mode 100644 vendor/libghostty-vt/src/lib/string.zig create mode 100644 vendor/libghostty-vt/src/lib/tinyio/posix.zig create mode 100644 vendor/libghostty-vt/src/lib/tinyio/test.zig create mode 100644 vendor/libghostty-vt/src/lib/tinyio/windows.zig delete mode 100644 vendor/libghostty-vt/src/os/args.zig create mode 100644 vendor/libghostty-vt/src/os/edit.zig delete mode 100644 vendor/libghostty-vt/src/os/env.zig create mode 100644 vendor/libghostty-vt/src/os/stderr.zig create mode 100644 vendor/libghostty-vt/src/quirks_memset.zig create mode 100644 vendor/libghostty-vt/src/synthetic/cli/Styled.zig create mode 100644 vendor/libghostty-vt/src/terminal/c/io.zig create mode 100644 vendor/libghostty-vt/src/terminal/c/search.zig create mode 100644 vendor/libghostty-vt/src/terminal/c/snapshot.zig create mode 100644 vendor/libghostty-vt/src/terminal/c/types-schema-verify.py create mode 100644 vendor/libghostty-vt/src/terminal/c/types.schema.json create mode 100644 vendor/libghostty-vt/src/terminal/fraction.zig create mode 100644 vendor/libghostty-vt/src/terminal/kitty/clipboard.zig create mode 100644 vendor/libghostty-vt/src/terminal/kitty/clipboard_command.zig create mode 100644 vendor/libghostty-vt/src/terminal/kitty/clipboard_grants.zig create mode 100644 vendor/libghostty-vt/src/terminal/kitty/clipboard_response.zig create mode 100644 vendor/libghostty-vt/src/terminal/kitty/clipboard_write.zig create mode 100644 vendor/libghostty-vt/src/terminal/kitty/dnd.zig create mode 100644 vendor/libghostty-vt/src/terminal/kitty/dnd_command.zig create mode 100644 vendor/libghostty-vt/src/terminal/kitty/dnd_drop.zig create mode 100644 vendor/libghostty-vt/src/terminal/kitty/dnd_response.zig create mode 100644 vendor/libghostty-vt/src/terminal/kitty/dnd_test.zig create mode 100644 vendor/libghostty-vt/src/terminal/kitty/graphics_animation.zig create mode 100644 vendor/libghostty-vt/src/terminal/kitty/graphics_pixel.zig create mode 100644 vendor/libghostty-vt/src/terminal/kitty/windows.zig create mode 100644 vendor/libghostty-vt/src/terminal/osc/kitty_metadata.zig create mode 100644 vendor/libghostty-vt/src/terminal/osc/parsers/kitty_desktop_notification.zig create mode 100644 vendor/libghostty-vt/src/terminal/paste.zig create mode 100644 vendor/libghostty-vt/src/terminal/search/terminal.zig create mode 100644 vendor/libghostty-vt/src/terminal/snapshot/AGENTS.md create mode 100644 vendor/libghostty-vt/src/terminal/snapshot/checkpoint.zig create mode 100644 vendor/libghostty-vt/src/terminal/snapshot/continuation.zig create mode 100644 vendor/libghostty-vt/src/terminal/snapshot/envelope.zig create mode 100644 vendor/libghostty-vt/src/terminal/snapshot/fixture.zig create mode 100644 vendor/libghostty-vt/src/terminal/snapshot/grid.zig create mode 100644 vendor/libghostty-vt/src/terminal/snapshot/history.zig create mode 100644 vendor/libghostty-vt/src/terminal/snapshot/hyperlink.zig create mode 100644 vendor/libghostty-vt/src/terminal/snapshot/io.zig create mode 100644 vendor/libghostty-vt/src/terminal/snapshot/main.zig create mode 100644 vendor/libghostty-vt/src/terminal/snapshot/page.zig create mode 100644 vendor/libghostty-vt/src/terminal/snapshot/record.zig create mode 100644 vendor/libghostty-vt/src/terminal/snapshot/screen.zig create mode 100644 vendor/libghostty-vt/src/terminal/snapshot/snapshot.ksy create mode 100644 vendor/libghostty-vt/src/terminal/snapshot/snapshot.zig create mode 100644 vendor/libghostty-vt/src/terminal/snapshot/style.zig create mode 100644 vendor/libghostty-vt/src/terminal/snapshot/terminal.zig create mode 100644 vendor/libghostty-vt/src/terminal/snapshot/testdata/checkpoint-ready-v1.hex create mode 100644 vendor/libghostty-vt/src/terminal/snapshot/testdata/complete-v1.hex create mode 100644 vendor/libghostty-vt/src/terminal/snapshot/testdata/continuation-apc-v1.hex create mode 100644 vendor/libghostty-vt/src/terminal/snapshot/testdata/continuation-csi-v1.hex create mode 100644 vendor/libghostty-vt/src/terminal/snapshot/testdata/continuation-dcs-v1.hex create mode 100644 vendor/libghostty-vt/src/terminal/snapshot/testdata/continuation-esc-v1.hex create mode 100644 vendor/libghostty-vt/src/terminal/snapshot/testdata/continuation-ground-v1.hex create mode 100644 vendor/libghostty-vt/src/terminal/snapshot/testdata/continuation-osc-v1.hex create mode 100644 vendor/libghostty-vt/src/terminal/snapshot/testdata/continuation-utf8-v1.hex create mode 100644 vendor/libghostty-vt/src/terminal/snapshot/testdata/envelope-v1.hex create mode 100644 vendor/libghostty-vt/src/terminal/snapshot/testdata/grid-v1.hex create mode 100644 vendor/libghostty-vt/src/terminal/snapshot/testdata/history-header-v1.hex create mode 100644 vendor/libghostty-vt/src/terminal/snapshot/testdata/hyperlink-explicit-v1.hex create mode 100644 vendor/libghostty-vt/src/terminal/snapshot/testdata/hyperlink-implicit-v1.hex create mode 100644 vendor/libghostty-vt/src/terminal/snapshot/testdata/page-empty-record-v1.hex create mode 100644 vendor/libghostty-vt/src/terminal/snapshot/testdata/page-header-v1.hex create mode 100644 vendor/libghostty-vt/src/terminal/snapshot/testdata/page-v1.hex create mode 100644 vendor/libghostty-vt/src/terminal/snapshot/testdata/record-page-header-v1.hex create mode 100644 vendor/libghostty-vt/src/terminal/snapshot/testdata/screen-header-v1.hex create mode 100644 vendor/libghostty-vt/src/terminal/snapshot/testdata/screen-saved-cursor-v1.hex create mode 100644 vendor/libghostty-vt/src/terminal/snapshot/testdata/style-v1.hex create mode 100644 vendor/libghostty-vt/src/terminal/snapshot/testdata/terminal-header-v1.hex create mode 100755 vendor/libghostty-vt/src/terminal/snapshot/verify-kaitai.py create mode 100644 vendor/libghostty-vt/src/terminal/stream_continuation.zig (limited to 'vendor/libghostty-vt/src') diff --git a/vendor/libghostty-vt/src/App.zig b/vendor/libghostty-vt/src/App.zig index 93ee7dea..2ee9f936 100644 --- a/vendor/libghostty-vt/src/App.zig +++ b/vendor/libghostty-vt/src/App.zig @@ -15,6 +15,7 @@ const Config = configpkg.Config; const BlockingQueue = @import("datastruct/main.zig").BlockingQueue; const renderer = @import("renderer.zig"); const font = @import("font/main.zig"); +const global = @import("global.zig"); const log = std.log.scoped(.app); @@ -56,7 +57,7 @@ font_grid_set: font.SharedGridSet, // Used to rate limit desktop notifications. Some platforms (notably macOS) will // run out of resources if desktop notifications are sent too fast and the OS // will kill Ghostty. -last_notification_time: ?std.time.Instant = null, +last_notification_time: ?std.Io.Timestamp = null, last_notification_digest: u64 = 0, /// The conditional state of the configuration. See the equivalent field @@ -77,6 +78,32 @@ pub fn create(alloc: Allocator) CreateError!*App { var app = try alloc.create(App); errdefer alloc.destroy(app); try app.init(alloc); + + // If font discovery supports warmup, then we call it. Some font + // mechanisms (e.g. CoreText) have a multi-millisecond one-time cost + // on startup. + if (comptime @hasDecl(font.Discover, "warmup")) { + if (std.Thread.spawn( + .{}, + font.Discover.warmup, + .{}, + )) |thr| thr.detach() else |err| { + log.warn("font warmup thread spawn failed err={}", .{err}); + } + } + + // Same for the renderer's graphics API (e.g. Metal), which pays + // one-time framework initialization costs on first use. + if (comptime @hasDecl(renderer.Renderer.API, "warmup")) { + if (std.Thread.spawn( + .{}, + renderer.Renderer.API.warmup, + .{}, + )) |thr| thr.detach() else |err| { + log.warn("renderer warmup thread spawn failed err={}", .{err}); + } + } + return app; } @@ -95,7 +122,7 @@ pub fn init( self.* = .{ .alloc = alloc, - .surfaces = .{}, + .surfaces = .empty, .mailbox = .{}, .font_grid_set = font_grid_set, .config_conditional_state = .{}, @@ -236,7 +263,7 @@ pub fn needsConfirmQuit(self: *const App) bool { /// Drain the mailbox. fn drainMailbox(self: *App, rt_app: *apprt.App) !void { - while (self.mailbox.pop()) |message| { + while (self.mailbox.pop(global.io())) |message| { if (comptime std.log.logEnabled(.debug, .app)) { switch (message) { // these tend to be way too verbose for normal debugging @@ -245,7 +272,15 @@ fn drainMailbox(self: *App, rt_app: *apprt.App) !void { } } switch (message) { - .open_config => try self.performAction(rt_app, .open_config), + .open_config => |v| try self.performAction( + rt_app, + .{ + .open_config = switch (v) { + .os_open => .os_open, + .new_window => .new_window, + }, + }, + ), .new_window => |msg| try self.newWindow(rt_app, msg), .close => |surface| self.closeSurface(surface), .surface_message => |msg| try self.surfaceMessage(msg.surface, msg.message), @@ -424,7 +459,14 @@ pub fn performAction( .ignore => {}, .quit => _ = try rt_app.performAction(.app, .quit, {}), .new_window => _ = try self.newWindow(rt_app, .{ .parent = null }), - .open_config => _ = try rt_app.performAction(.app, .open_config, {}), + .open_config => |v| _ = try rt_app.performAction( + .app, + .open_config, + switch (v) { + .os_open => .os_open, + .new_window => .new_window, + }, + ), .reload_config => _ = try rt_app.performAction(.app, .reload_config, .{}), .close_all_windows => _ = try rt_app.performAction(.app, .close_all_windows, {}), .toggle_quick_terminal => _ = try rt_app.performAction(.app, .toggle_quick_terminal, {}), @@ -475,8 +517,8 @@ pub fn performAllAction( // are logged but processing continues. .surface => for (self.surfaces.items) |surface| { _ = surface.core().performBindingAction(action) catch |err| { - log.warn("error performing binding action on surface ptr={X} err={}", .{ - @intFromPtr(surface), + log.warn("error performing binding action on surface id={x} err={}", .{ + surface.core().id, err, }); }; @@ -526,7 +568,7 @@ fn hasRtSurface(self: *const App, surface: *apprt.Surface) bool { /// The message types that can be sent to the app thread. pub const Message = union(enum) { // Open the configuration file - open_config: void, + open_config: OpenConfig, /// Create a new terminal window. new_window: NewWindow, @@ -554,6 +596,13 @@ pub const Message = union(enum) { /// The parent surface parent: ?*Surface = null, }; + + pub const OpenConfig = enum { + /// Open the config in the OS default editor. + os_open, + /// Open the config in a new window using $EDITOR or $VISUAL + new_window, + }; }; /// Mailbox is the way that other threads send the app thread messages. @@ -566,7 +615,7 @@ pub const Mailbox = struct { /// Send a message to the surface. pub fn push(self: Mailbox, msg: Message, timeout: Queue.Timeout) Queue.Size { - const result = self.mailbox.push(msg, timeout); + const result = self.mailbox.push(global.io(), msg, timeout); // Wake up our app loop self.rt_app.wakeup(); diff --git a/vendor/libghostty-vt/src/Command.zig b/vendor/libghostty-vt/src/Command.zig index b8193625..3fc498a9 100644 --- a/vendor/libghostty-vt/src/Command.zig +++ b/vendor/libghostty-vt/src/Command.zig @@ -14,12 +14,15 @@ //! * posix_spawn is used for Mac, but doesn't support the necessary //! features for tty setup. //! +//! +//! TODO: This may have changed a lot now with the new I/O implementations in +//! >= 0.16.0, so this might warrant a recheck. const Command = @This(); const std = @import("std"); const builtin = @import("builtin"); const configpkg = @import("config.zig"); -const global_state = &@import("global.zig").state; +const global = @import("global.zig"); const internal_os = @import("os/main.zig"); const windows = internal_os.windows; const TempDir = internal_os.TempDir; @@ -29,8 +32,8 @@ const posix = std.posix; const debug = std.debug; const testing = std.testing; const Allocator = std.mem.Allocator; -const File = std.fs.File; -const EnvMap = std.process.EnvMap; +const File = std.Io.File; +const EnvMap = std.process.Environ.Map; const apprt = @import("apprt.zig"); /// Function prototype for a function executed /in the child process/ after the @@ -65,7 +68,7 @@ env: ?*const EnvMap = null, /// Working directory to change to in the child process. If not set, the /// working directory of the calling process is preserved. -cwd: ?[]const u8 = null, +cwd: ?[:0]const u8 = null, /// The file handle to set for stdin/out/err. If this isn't set, we do /// nothing explicitly so it is up to the behavior of the operating system. @@ -98,7 +101,7 @@ rt_post_fork_info: RtPostForkInfo, /// If set, then the process will be created attached to this pseudo console. /// `stdin`, `stdout`, and `stderr` will be ignored if set. -pseudo_console: if (builtin.os.tag == .windows) ?windows.exp.HPCON else void = +pseudo_console: if (builtin.os.tag == .windows) ?windows.HPCON else void = if (builtin.os.tag == .windows) null else {}, /// User data that is sent to the callback. Set with setData and getData @@ -106,7 +109,7 @@ pseudo_console: if (builtin.os.tag == .windows) ?windows.exp.HPCON else void = data: ?*anyopaque = null, /// Process ID is set after start is called. -pid: ?posix.pid_t = null, +pid: ?posix.system.pid_t = null, /// The various methods a process may exit. pub const Exit = if (builtin.os.tag == .windows) union(enum) { @@ -128,9 +131,9 @@ pub const Exit = if (builtin.os.tag == .windows) union(enum) { return if (posix.W.IFEXITED(status)) Exit{ .Exited = posix.W.EXITSTATUS(status) } else if (posix.W.IFSIGNALED(status)) - Exit{ .Signal = posix.W.TERMSIG(status) } + Exit{ .Signal = @intFromEnum(posix.W.TERMSIG(status)) } else if (posix.W.IFSTOPPED(status)) - Exit{ .Stopped = posix.W.STOPSIG(status) } + Exit{ .Stopped = @intFromEnum(posix.W.STOPSIG(status)) } else Exit{ .Unknown = status }; } @@ -186,7 +189,7 @@ fn startPosix(self: *Command, arena: Allocator) !void { @compileError("missing env vars"); // Fork. - const pid = try posix.fork(); + const pid = try fork(); if (pid != 0) { // Parent, return immediately. @@ -205,37 +208,68 @@ fn startPosix(self: *Command, arena: Allocator) !void { if (self.stderr) |f| setupFd(f.handle, posix.STDERR_FILENO) catch return error.ExecFailedInChild; - // Setup our working directory - if (self.cwd) |cwd| posix.chdir(cwd) catch { - // This can fail if we don't have permission to go to - // this directory or if due to race conditions it doesn't - // exist or any various other reasons. We don't want to - // crash the entire process if this fails so we ignore it. - // We don't log because that'll show up in the output. - }; + // Setup our working directory. + // + // NOTE: this can fail if we don't have permission to go to this directory + // or if due to race conditions it doesn't exist or any various other + // reasons. We don't want to crash the entire process if this fails so we + // ignore it. We don't log because that'll show up in the output. + if (self.cwd) |cwd| _ = posix.system.chdir(cwd); // Restore any rlimits that were set by Ghostty. This might fail but // any failures are ignored (its best effort). - global_state.rlimits.restore(); + global.rlimits().restore(); // If there are pre exec callbacks, call them now. - if (self.os_pre_exec) |f| if (f(self)) |exitcode| posix.exit(exitcode); - if (self.rt_pre_exec) |f| if (f(self)) |exitcode| posix.exit(exitcode); + if (self.os_pre_exec) |f| if (f(self)) |exitcode| posix.system.exit(exitcode); + if (self.rt_pre_exec) |f| if (f(self)) |exitcode| posix.system.exit(exitcode); + + const err: posix.E = execve: { + // This functionality has been taken from Zig stdlib, a simplified + // version of the exec bits with PATH search so that we can just + // offload to execve below. + const file_slice = std.mem.sliceTo(self.path, 0); + if (std.mem.findScalar(u8, file_slice, '/') != null) { + break :execve posix.errno(posix.system.execve(self.path, argsZ, envp)); + } - // Finally, replace our process. - // Note: we must use the "p"-variant of exec here because we - // do not guarantee our command is looked up already in the path. - const err = posix.execvpeZ(self.path, argsZ, envp); + var path_expanded_buf: [std.fs.max_path_bytes]u8 = undefined; + const PATH = global.environ().getPosix("PATH") orelse "/usr/local/bin:/bin/:/usr/bin"; + var it = std.mem.tokenizeScalar(u8, PATH, ':'); + var err: posix.system.E = .NOENT; + var seen_eacces = false; + + while (it.next()) |search_path| { + const path_len = search_path.len + file_slice.len + 1; + if (path_expanded_buf.len < path_len + 1) break :execve .NAMETOOLONG; + @memcpy(path_expanded_buf[0..search_path.len], search_path); + path_expanded_buf[search_path.len] = '/'; + @memcpy(path_expanded_buf[search_path.len + 1 ..][0..file_slice.len], file_slice); + path_expanded_buf[path_len] = 0; + const full_path = path_expanded_buf[0..path_len :0].ptr; + // Replace here, switch on error (any error means that replace + // failed, but we might need to retry). + err = posix.errno(posix.system.execve(full_path, argsZ, envp)); + switch (err) { + .ACCES => seen_eacces = true, + .NOENT, .NOTDIR => {}, + else => break :execve err, + } + } + + if (seen_eacces) break :execve .ACCES; + break :execve err; + }; // If we are executing this code, the exec failed. We're in the // child process so there isn't much we can do. We try to output // something reasonable. Its important to note we MUST NOT return // any other error condition from here on out. var stderr_buf: [1024]u8 = undefined; - var stderr_writer = std.fs.File.stderr().writer(&stderr_buf); + var stderr_writer = std.Io.File.stderr().writer(global.io(), &stderr_buf); const stderr = &stderr_writer.interface; switch (err) { - error.FileNotFound => stderr.print( + posix.system.E.NOENT => stderr.print( \\Requested executable not found. Please verify the command is on \\the PATH and try again. \\ @@ -243,11 +277,11 @@ fn startPosix(self: *Command, arena: Allocator) !void { .{}, ) catch {}, - else => stderr.print( - \\exec syscall failed with unexpected error: {} + else => |e| stderr.print( + \\exec syscall failed with unexpected error: E{s} \\ , - .{err}, + .{@tagName(e)}, ) catch {}, } stderr.flush() catch {}; @@ -257,6 +291,17 @@ fn startPosix(self: *Command, arena: Allocator) !void { return error.ExecFailedInChild; } +/// Wrapper for the raw fork syscall. This preserves the error handling from +/// the std.posix wrapper that was removed in Zig 0.16. +fn fork() !posix.pid_t { + const rc = posix.system.fork(); + switch (posix.errno(rc)) { + .SUCCESS => return @intCast(rc), + .AGAIN, .NOMEM => return error.SystemResources, + else => |err| return posix.unexpectedErrno(err), + } +} + fn startWindows(self: *Command, arena: Allocator) !void { const cwd_w = if (self.cwd) |cwd| try std.unicode.utf8ToUtf16LeAllocZ(arena, cwd) else null; @@ -275,15 +320,37 @@ fn startWindows(self: *Command, arena: Allocator) !void { const env_w = if (self.env) |env_map| try createWindowsEnvBlock(arena, env_map) else null; const any_null_fd = self.stdin == null or self.stdout == null or self.stderr == null; - const null_fd = if (any_null_fd) try windows.OpenFile( - &[_]u16{ '\\', 'D', 'e', 'v', 'i', 'c', 'e', '\\', 'N', 'u', 'l', 'l' }, - .{ - .access_mask = windows.GENERIC_READ | windows.SYNCHRONIZE, - .share_access = windows.FILE_SHARE_READ, - .creation = windows.OPEN_EXISTING, - }, - ) else null; - defer if (null_fd) |fd| posix.close(fd); + const null_fd = if (any_null_fd) null_fd: { + // path = "\Device\Null" + const path = [_]u16{ '\\', 'D', 'e', 'v', 'i', 'c', 'e', '\\', 'N', 'u', 'l', 'l' }; + var path_unicode_string: windows.UNICODE_STRING = .init(&path); + var attrs: windows.OBJECT_ATTRIBUTES = .{ .ObjectName = &path_unicode_string }; + + var fd: windows.HANDLE = undefined; + var io_status: windows.IO_STATUS_BLOCK = undefined; // unused + const result = windows.exp.ntdll.NtCreateFile( + &fd, + .{ .GENERIC = .{ .READ = true }, .STANDARD = .{ .SYNCHRONIZE = true } }, + &attrs, + &io_status, + null, + windows.FILE_ATTRIBUTE_NORMAL, + windows.FILE_SHARE_READ, + windows.OPEN_EXISTING, + windows.FILE_NON_DIRECTORY_FILE, + null, + 0, + ); + + if (result != .SUCCESS) { + return windows.unexpectedStatus(result); + } + + break :null_fd fd; + } else null; + defer { + if (null_fd) |fd| _ = windows.exp.kernel32.CloseHandle(fd); + } // TODO: In the case of having FDs instead of pty, need to set up // attributes such that the child process only inherits these handles, @@ -304,17 +371,17 @@ fn startWindows(self: *Command, arena: Allocator) !void { 1, 0, &attribute_list_size, - ) == 0) return windows.unexpectedError(windows.kernel32.GetLastError()); + ) == windows.FALSE) return windows.unexpectedError(windows.GetLastError()); if (windows.exp.kernel32.UpdateProcThreadAttribute( attribute_list_buf.ptr, 0, - windows.exp.PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE, + windows.PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE, pseudo_console, - @sizeOf(windows.exp.HPCON), + @sizeOf(windows.HPCON), null, null, - ) == 0) return windows.unexpectedError(windows.kernel32.GetLastError()); + ) == windows.FALSE) return windows.unexpectedError(windows.GetLastError()); break :b .{ attribute_list_buf.ptr, null, null, null }; } else b: { @@ -324,9 +391,9 @@ fn startWindows(self: *Command, arena: Allocator) !void { break :b .{ null, stdin, stdout, stderr }; }; - var startup_info_ex = windows.exp.STARTUPINFOEX{ + var startup_info_ex = windows.STARTUPINFOEX{ .StartupInfo = .{ - .cb = if (attribute_list != null) @sizeOf(windows.exp.STARTUPINFOEX) else @sizeOf(windows.STARTUPINFOW), + .cb = if (attribute_list != null) @sizeOf(windows.STARTUPINFOEX) else @sizeOf(windows.STARTUPINFOW), .hStdError = stderr, .hStdOutput = stdout, .hStdInput = stdin, @@ -348,8 +415,8 @@ fn startWindows(self: *Command, arena: Allocator) !void { .lpAttributeList = attribute_list, }; - var flags: windows.DWORD = windows.exp.CREATE_UNICODE_ENVIRONMENT; - if (attribute_list != null) flags |= windows.exp.EXTENDED_STARTUPINFO_PRESENT; + var flags: windows.DWORD = windows.CREATE_UNICODE_ENVIRONMENT; + if (attribute_list != null) flags |= windows.EXTENDED_STARTUPINFO_PRESENT; var process_information: windows.PROCESS_INFORMATION = undefined; if (windows.exp.kernel32.CreateProcessW( @@ -363,21 +430,18 @@ fn startWindows(self: *Command, arena: Allocator) !void { if (cwd_w) |w| w.ptr else null, @ptrCast(&startup_info_ex.StartupInfo), &process_information, - ) == 0) return windows.unexpectedError(windows.kernel32.GetLastError()); + ) == windows.FALSE) return windows.unexpectedError(windows.GetLastError()); self.pid = process_information.hProcess; } fn setupFd(src: File.Handle, target: i32) !void { - switch (builtin.os.tag) { - .linux => { - // We use dup3 so that we can clear CLO_ON_EXEC. We do NOT want this - // file descriptor to be closed on exec since we're exactly exec-ing after - // this. + const PosixCall = struct { + fn f(func: anytype, args: anytype) !usize { while (true) { - const rc = linux.dup3(src, target, 0); + const rc = @call(.auto, func, args); switch (posix.errno(rc)) { - .SUCCESS => break, + .SUCCESS => return @intCast(rc), .INTR => continue, .AGAIN, .ACCES => return error.Locked, .BADF => unreachable, @@ -391,16 +455,28 @@ fn setupFd(src: File.Handle, target: i32) !void { else => |err| return posix.unexpectedErrno(err), } } + } + }; + + switch (builtin.os.tag) { + .linux => { + // We use dup3 so that we can clear CLO_ON_EXEC. We do NOT want this + // file descriptor to be closed on exec since we're exactly exec-ing after + // this. + _ = try PosixCall.f(linux.dup3, .{ src, target, 0 }); }, .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); + const flags = try PosixCall.f(posix.system.fcntl, .{ src, posix.F.GETFD }); if (flags & posix.FD_CLOEXEC != 0) { - _ = try posix.fcntl(src, posix.F.SETFD, flags & ~@as(u32, posix.FD_CLOEXEC)); + _ = try PosixCall.f( + posix.system.fcntl, + .{ src, posix.F.SETFD, flags & ~@as(u32, posix.FD_CLOEXEC) }, + ); } - try posix.dup2(src, target); + _ = try PosixCall.f(posix.system.dup2, .{ src, target }); }, else => @compileError("unsupported platform"), } @@ -411,21 +487,29 @@ pub fn wait(self: Command, block: bool) !Exit { if (comptime builtin.os.tag == .windows) { // Block until the process exits. This returns immediately if the // process already exited. - const result = windows.kernel32.WaitForSingleObject(self.pid.?, windows.INFINITE); + // + // NOTE: We can use the pid directly as posix.system.pid_t is still an + // alias for a handle under Windows. We might want to keep an eye on if + // this changes, though. + const result = windows.exp.kernel32.WaitForSingleObject(self.pid.?, windows.INFINITE); if (result == windows.WAIT_FAILED) { - return windows.unexpectedError(windows.kernel32.GetLastError()); + return windows.unexpectedError(windows.GetLastError()); } var exit_code: windows.DWORD = undefined; - const has_code = windows.kernel32.GetExitCodeProcess(self.pid.?, &exit_code) != 0; + const has_code = windows.exp.kernel32.GetExitCodeProcess(self.pid.?, &exit_code) != windows.FALSE; if (!has_code) { - return windows.unexpectedError(windows.kernel32.GetLastError()); + return windows.unexpectedError(windows.GetLastError()); } return .{ .Exited = exit_code }; } - const res = if (block) posix.waitpid(self.pid.?, 0) else res: { + const status: u32 = if (block) wait_block: { + var status: if (builtin.link_libc) c_int else u32 = undefined; + _ = try waitPid(self.pid.?, &status, 0); + break :wait_block @bitCast(status); + } else wait_nohang: { // We specify NOHANG because its not our fault if the process we launch // for the tty doesn't properly waitpid its children. We don't want // to hang the terminal over it. @@ -434,12 +518,32 @@ pub fn wait(self: Command, block: bool) !Exit { // wait call has not been performed, so we need to keep trying until we get // a non-zero pid back, otherwise we end up with zombie processes. while (true) { - const res = posix.waitpid(self.pid.?, std.c.W.NOHANG); - if (res.pid != 0) break :res res; + var status: if (builtin.link_libc) c_int else u32 = undefined; + const pid = try waitPid(self.pid.?, &status, posix.system.W.NOHANG); + if (pid != 0) break :wait_nohang @bitCast(status); } }; - return .init(res.status); + return .init(status); +} + +/// Wrapper for the raw waitpid syscall. Status is only initialized on success; +/// interrupted waits are retried and all other errors are propagated. +fn waitPid( + pid: posix.pid_t, + status: *if (builtin.link_libc) c_int else u32, + flags: u32, +) !posix.pid_t { + while (true) { + const rc = posix.system.waitpid(pid, status, @intCast(flags)); + switch (posix.errno(rc)) { + .SUCCESS => return @intCast(rc), + .INTR => continue, + .CHILD => return error.NoChildProcess, + .INVAL => return error.InvalidWaitOptions, + else => |err| return posix.unexpectedErrno(err), + } + } } /// Sets command->data to data. @@ -587,7 +691,7 @@ test "Command: os pre exec 1" { fn do(_: *Command) ?u8 { // This runs in the child, so we can exit and it won't // kill the test runner. - posix.exit(42); + posix.system.exit(42); } }).do, .rt_pre_exec = null, @@ -638,7 +742,7 @@ test "Command: rt pre exec 1" { fn do(_: *Command) ?u8 { // This runs in the child, so we can exit and it won't // kill the test runner. - posix.exit(42); + posix.system.exit(42); } }).do, .rt_post_fork = null, @@ -697,27 +801,31 @@ test "Command: rt post fork 1" { try testing.expectError(error.PostForkError, cmd.testingStart()); } -fn createTestStdout(dir: std.fs.Dir) !File { - const file = try dir.createFile("stdout.txt", .{ .read = true }); +fn createTestStdout(io: std.Io, dir: std.Io.Dir) !File { + const file = try dir.createFile(io, "stdout.txt", .{ .read = true }); if (builtin.os.tag == .windows) { - try windows.SetHandleInformation( + if (windows.exp.kernel32.SetHandleInformation( file.handle, windows.HANDLE_FLAG_INHERIT, windows.HANDLE_FLAG_INHERIT, - ); + ) == windows.FALSE) { + return windows.unexpectedError(windows.GetLastError()); + } } return file; } -fn createTestStderr(dir: std.fs.Dir) !File { - const file = try dir.createFile("stderr.txt", .{ .read = true }); +fn createTestStderr(io: std.Io, dir: std.Io.Dir) !File { + const file = try dir.createFile(io, "stderr.txt", .{ .read = true }); if (builtin.os.tag == .windows) { - try windows.SetHandleInformation( + if (windows.exp.kernel32.SetHandleInformation( file.handle, windows.HANDLE_FLAG_INHERIT, windows.HANDLE_FLAG_INHERIT, - ); + ) == windows.FALSE) { + return windows.unexpectedError(windows.GetLastError()); + } } return file; @@ -726,8 +834,8 @@ fn createTestStderr(dir: std.fs.Dir) !File { test "Command: redirect stdout to file" { var td = try TempDir.init(); defer td.deinit(); - var stdout = try createTestStdout(td.dir); - defer stdout.close(); + var stdout = try createTestStdout(testing.io, td.dir); + defer stdout.close(testing.io); var cmd: Command = if (builtin.os.tag == .windows) .{ .path = "C:\\Windows\\System32\\whoami.exe", @@ -756,8 +864,13 @@ test "Command: redirect stdout to file" { try testing.expectEqual(@as(u32, 0), @as(u32, exit.Exited)); // Read our stdout - try stdout.seekTo(0); - const contents = try stdout.readToEndAlloc(testing.allocator, 1024 * 128); + const contents = contents: { + const size = (try stdout.stat(testing.io)).size; + const data = try testing.allocator.alloc(u8, size); + errdefer testing.allocator.free(data); + try testing.expectEqual(size, try stdout.readPositionalAll(testing.io, data, 0)); + break :contents data; + }; defer testing.allocator.free(contents); try testing.expect(contents.len > 0); } @@ -765,8 +878,8 @@ test "Command: redirect stdout to file" { test "Command: custom env vars" { var td = try TempDir.init(); defer td.deinit(); - var stdout = try createTestStdout(td.dir); - defer stdout.close(); + var stdout = try createTestStdout(testing.io, td.dir); + defer stdout.close(testing.io); var env = EnvMap.init(testing.allocator); defer env.deinit(); @@ -801,8 +914,13 @@ test "Command: custom env vars" { try testing.expect(exit.Exited == 0); // Read our stdout - try stdout.seekTo(0); - const contents = try stdout.readToEndAlloc(testing.allocator, 4096); + const contents = contents: { + const size = (try stdout.stat(testing.io)).size; + const data = try testing.allocator.alloc(u8, size); + errdefer testing.allocator.free(data); + try testing.expectEqual(size, try stdout.readPositionalAll(testing.io, data, 0)); + break :contents data; + }; defer testing.allocator.free(contents); if (builtin.os.tag == .windows) { @@ -815,8 +933,8 @@ test "Command: custom env vars" { test "Command: custom working directory" { var td = try TempDir.init(); defer td.deinit(); - var stdout = try createTestStdout(td.dir); - defer stdout.close(); + var stdout = try createTestStdout(testing.io, td.dir); + defer stdout.close(testing.io); var cmd: Command = if (builtin.os.tag == .windows) .{ .path = "C:\\Windows\\System32\\cmd.exe", @@ -847,8 +965,13 @@ test "Command: custom working directory" { try testing.expect(exit.Exited == 0); // Read our stdout - try stdout.seekTo(0); - const contents = try stdout.readToEndAlloc(testing.allocator, 4096); + const contents = contents: { + const size = (try stdout.stat(testing.io)).size; + const data = try testing.allocator.alloc(u8, size); + errdefer testing.allocator.free(data); + try testing.expectEqual(size, try stdout.readPositionalAll(testing.io, data, 0)); + break :contents data; + }; defer testing.allocator.free(contents); if (builtin.os.tag == .windows) { @@ -872,10 +995,10 @@ test "Command: posix fork handles execveZ failure" { } var td = try TempDir.init(); defer td.deinit(); - var stdout = try createTestStdout(td.dir); - defer stdout.close(); - var stderr = try createTestStderr(td.dir); - defer stderr.close(); + var stdout = try createTestStdout(testing.io, td.dir); + defer stdout.close(testing.io); + var stderr = try createTestStderr(testing.io, td.dir); + defer stderr.close(testing.io); var cmd: Command = .{ .path = "/not/a/binary", @@ -904,7 +1027,7 @@ fn testingStart(self: *Command) !void { self.start(testing.allocator) catch |err| { if (err == error.ExecFailedInChild) { // I am a child process, I must not get confused and continue running the rest of the test suite. - posix.exit(1); + posix.system.exit(1); } return err; }; diff --git a/vendor/libghostty-vt/src/Surface.zig b/vendor/libghostty-vt/src/Surface.zig index 2c4edc49..77529212 100644 --- a/vendor/libghostty-vt/src/Surface.zig +++ b/vendor/libghostty-vt/src/Surface.zig @@ -20,8 +20,9 @@ const builtin = @import("builtin"); const assert = @import("quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; -const global_state = &@import("global.zig").state; +const global = @import("global.zig"); const oni = @import("oniguruma"); +const simd = @import("simd/main.zig"); const crash = @import("crash/main.zig"); const unicode = @import("unicode/main.zig"); const rendererpkg = @import("renderer.zig"); @@ -154,6 +155,10 @@ child_exited: bool = false, /// to let us know. focused: bool = true, +/// Whether this surface may be visible. Unknown visibility is considered +/// visible so reporting remains conservative. +visible: bool = true, + /// Used to determine whether to continuously scroll. selection_scroll_active: bool = false, @@ -168,13 +173,13 @@ readonly: bool = false, /// precision timestamp. It does not necessarily need to correspond to the /// actual time, but we must be able to compare two subsequent timestamps to get /// the wall clock time that has elapsed between timestamps. -command_timer: ?std.time.Instant = null, +command_timer: ?std.Io.Timestamp = null, /// Search state search: ?Search = null, /// Used to rate limit BEL handling. -last_bell_time: ?std.time.Instant = null, +last_bell_time: ?std.Io.Timestamp = null, /// The effect of an input event. This can be used by callers to take /// the appropriate action after an input event. For example, key @@ -330,6 +335,7 @@ const DerivedConfig = struct { title: ?[:0]const u8, title_report: bool, links: []DerivedConfig.Link, + link_osc8: bool, link_previews: configpkg.LinkPreviews, scroll_to_bottom: configpkg.Config.ScrollToBottom, notify_on_command_finish: configpkg.Config.NotifyOnCommandFinish, @@ -409,6 +415,7 @@ const DerivedConfig = struct { .title = config.title, .title_report = config.@"title-report", .links = links, + .link_osc8 = config.@"link-osc8", .link_previews = config.@"link-previews", .scroll_to_bottom = config.@"scroll-to-bottom", .notify_on_command_finish = config.@"notify-on-command-finish", @@ -559,8 +566,8 @@ pub fn init( errdefer renderer_impl.deinit(); // The mutex used to protect our renderer state. - const mutex = try alloc.create(std.Thread.Mutex); - mutex.* = .{}; + const mutex = try alloc.create(std.Io.Mutex); + mutex.* = .init; errdefer alloc.destroy(mutex); // Create the renderer thread @@ -581,7 +588,11 @@ pub fn init( self.* = .{ .id = id: { while (true) { - const candidate = std.crypto.random.int(u64); + const candidate = candidate: { + const rng_impl: std.Random.IoSource = .{ .io = global.io() }; + const rng = rng_impl.interface(); + break :candidate rng.int(u64); + }; if (candidate == 0) continue; break :id candidate; } @@ -631,13 +642,12 @@ pub fn init( var env = rt_surface.defaultTermioEnv() catch |err| env: { // If an error occurs, we don't want to block surface startup. log.warn("error getting env map for surface err={}", .{err}); - break :env internal_os.getEnvMap(alloc) catch - std.process.EnvMap.init(alloc); + break :env global.environMap() catch std.process.Environ.Map.init(alloc); }; errdefer env.deinit(); // don't leak GHOSTTY_LOG to any subprocesses - env.remove("GHOSTTY_LOG"); + _ = env.orderedRemove("GHOSTTY_LOG"); var buf: [18]u8 = undefined; try env.put( @@ -654,7 +664,7 @@ pub fn init( .shell_integration_features = config.@"shell-integration-features", .cursor_blink = config.@"cursor-style-blink", .working_directory = if (config.@"working-directory") |wd| wd.value() else null, - .resources_dir = global_state.resources_dir.host(), + .resources_dir = global.resourcesDir().host(), .term = config.term, .rt_pre_exec_info = .init(config), .rt_post_fork_info = .init(config), @@ -717,7 +727,7 @@ pub fn init( rendererpkg.Thread.threadMain, .{&self.renderer_thread}, ); - self.renderer_thr.setName("renderer") catch {}; + self.renderer_thr.setName(global.io(), "renderer") catch {}; // Start our IO thread self.io_thr = try std.Thread.spawn( @@ -725,7 +735,7 @@ pub fn init( termio.Thread.threadMain, .{ &self.io_thread, &self.io }, ); - self.io_thr.setName("io") catch {}; + self.io_thr.setName(global.io(), "io") catch {}; // Determine our initial window size if configured. We need to do this // quite late in the process because our height/width are in grid dimensions, @@ -834,7 +844,7 @@ pub fn deinit(self: *Surface) void { self.alloc.destroy(self.renderer_state.mutex); self.config.deinit(); - log.info("surface closed addr={x}", .{@intFromPtr(self)}); + log.info("surface closed id={x}", .{self.id}); } /// Close this surface. This will trigger the runtime to start the @@ -851,7 +861,7 @@ inline fn surfaceMailbox(self: *Surface) Mailbox { }; } -/// Queue a message for the IO thread. +/// Queue a message for the IO thread, taking ownership of `msg`. /// /// We centralize all our logic into this spot so we can intercept /// messages for example in readonly mode. @@ -866,7 +876,10 @@ fn queueIo( .write_small, .write_stable, .write_alloc, - => return, + => { + msg.deinit(); + return; + }, else => {}, } @@ -900,14 +913,14 @@ pub fn activateInspector(self: *Surface) !void { // Put the inspector onto the render state { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); assert(self.renderer_state.inspector == null); self.renderer_state.inspector = self.inspector; } // Notify our components we have an inspector active - _ = self.renderer_thread.mailbox.push(.{ .inspector = true }, .{ .forever = {} }); + _ = self.renderer_thread.mailbox.push(global.io(), .{ .inspector = true }, .{ .forever = {} }); self.queueIo(.{ .inspector = true }, .unlocked); } @@ -917,14 +930,14 @@ pub fn deactivateInspector(self: *Surface) void { // Remove the inspector from the render state { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); assert(self.renderer_state.inspector != null); self.renderer_state.inspector = null; } // Notify our components we have deactivated inspector - _ = self.renderer_thread.mailbox.push(.{ .inspector = false }, .{ .forever = {} }); + _ = self.renderer_thread.mailbox.push(global.io(), .{ .inspector = false }, .{ .forever = {} }); self.queueIo(.{ .inspector = false }, .unlocked); // Deinit the inspector @@ -948,8 +961,8 @@ pub fn needsConfirmQuit(self: *Surface) bool { .always => true, .false => false, .true => true: { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); break :true !self.io.terminal.cursorIsAtPrompt(); }, }; @@ -1049,6 +1062,10 @@ pub fn handleMessage(self: *Surface, msg: Message) !void { _ = try self.startClipboardRequest(.standard, .{ .osc_52_read = clipboard }); }, + .kitty_clipboard_read => |req| try self.kittyClipboardRead(req), + + .kitty_clipboard_write => |req| try self.kittyClipboardWrite(req), + .clipboard_write => |w| switch (w.req) { .small => |v| try self.clipboardWrite(v.data[0..v.len], w.clipboard_type), .stable => |v| try self.clipboardWrite(v, w.clipboard_type), @@ -1061,9 +1078,10 @@ pub fn handleMessage(self: *Surface, msg: Message) !void { .pwd_change => |w| { defer w.deinit(); - // We always allocate for this because we need to null-terminate. - const str = try self.alloc.dupeZ(u8, w.slice()); - defer self.alloc.free(str); + var stack = std.heap.stackFallback(256, self.alloc); + const alloc = stack.get(); + const str = try alloc.dupeZ(u8, w.slice()); + defer alloc.free(str); _ = try self.rt_app.performAction( .{ .surface = self }, @@ -1096,9 +1114,9 @@ pub fn handleMessage(self: *Surface, msg: Message) !void { .password_input => |v| try self.passwordInput(v), .ring_bell => bell: { - const now = std.time.Instant.now() catch unreachable; + const now: std.Io.Timestamp = .now(global.io(), .awake); if (self.last_bell_time) |last| { - if (now.since(last) < 100 * std.time.ns_per_ms) break :bell; + if (last.durationTo(now).toMilliseconds() < 100) break :bell; } self.last_bell_time = now; _ = self.rt_app.performAction( @@ -1126,15 +1144,22 @@ pub fn handleMessage(self: *Surface, msg: Message) !void { }, .start_command => { - self.command_timer = try .now(); + self.command_timer = .now(global.io(), .awake); }, .stop_command => |v| timer: { - const end: std.time.Instant = try .now(); + const end: std.Io.Timestamp = .now(global.io(), .awake); const start = self.command_timer orelse break :timer; self.command_timer = null; + const duration_raw = start.durationTo(end).nanoseconds; + assert(duration_raw >= 0 and duration_raw <= std.math.maxInt(u64)); - const duration: Duration = .{ .duration = end.since(start) }; + const duration: Duration = .{ + .duration = @as( + u64, + @intCast(std.math.clamp(start.durationTo(end).nanoseconds, 0, std.math.maxInt(u64))), + ), + }; log.debug("command took {f}", .{duration}); _ = self.rt_app.performAction( @@ -1185,8 +1210,8 @@ fn selectionScrollTick(self: *Surface) !void { const pos_vp = self.posToViewport(pos.x, pos.y); // We need our locked state for the remainder - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); const t: *terminal.Terminal = self.renderer_state.terminal; const selection = self.mouse.selection_gesture.autoscrollTick(t, .{ @@ -1276,8 +1301,8 @@ fn childExited(self: *Surface, info: apprt.surface.Message.ChildExited) void { // If the native GUI can't be shown, display a text message in the // terminal. - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); const t: *terminal.Terminal = self.renderer_state.terminal; t.carriageReturn(); t.linefeed() catch break :terminal; @@ -1316,8 +1341,8 @@ fn childExitedAbnormally( }); const runtime_str = try std.fmt.allocPrint(alloc, "{d} ms", .{info.runtime_ms}); - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); const t: *terminal.Terminal = self.renderer_state.terminal; // No matter what move the cursor back to the column 0. @@ -1383,8 +1408,8 @@ fn childExitedAbnormally( /// Called when the terminal detects there is a password input prompt. fn passwordInput(self: *Surface, v: bool) !void { { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); // If our password input state is unchanged then we don't // waste time doing anything more. @@ -1436,6 +1461,7 @@ fn searchCallback_( for (matches) |*m| m.* = try m.clone(alloc); _ = self.renderer_thread.mailbox.push( + global.io(), .{ .search_viewport_matches = .{ .arena = arena, .matches = matches, @@ -1454,6 +1480,7 @@ fn searchCallback_( const match = try sel.highlight.clone(alloc); _ = self.renderer_thread.mailbox.push( + global.io(), .{ .search_selected_match = .{ .arena = arena, .match = match, @@ -1469,6 +1496,7 @@ fn searchCallback_( } else { // Reset our selected match _ = self.renderer_thread.mailbox.push( + global.io(), .{ .search_selected_match = null }, .forever, ); @@ -1493,10 +1521,12 @@ fn searchCallback_( // When we quit, tell our renderer to reset any search state. .quit => { _ = self.renderer_thread.mailbox.push( + global.io(), .{ .search_selected_match = null }, .forever, ); _ = self.renderer_thread.mailbox.push( + global.io(), .{ .search_viewport_matches = .{ .arena = .init(self.alloc), .matches = &.{}, @@ -1539,8 +1569,8 @@ fn modsChanged(self: *Surface, mods: input.Mods) void { // highlight links. Additionally, mark the screen as dirty so // that the highlight state of all links is properly updated. { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); self.renderer_state.mouse.mods = self.mouseModsWithCapture(self.mouse.mods); // We use the clear screen dirty flag to force a rebuild of all @@ -1786,7 +1816,7 @@ pub fn updateConfig( termio_config_ptr.* = try termio.Termio.DerivedConfig.init(self.alloc, config); errdefer termio_config_ptr.deinit(); - _ = self.renderer_thread.mailbox.push(renderer_message, .{ .forever = {} }); + _ = self.renderer_thread.mailbox.push(global.io(), renderer_message, .{ .forever = {} }); self.queueIo(.{ .change_config = .{ .alloc = self.alloc, @@ -1907,8 +1937,8 @@ pub fn dumpText( alloc: Allocator, sel: terminal.Selection, ) !Text { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); return try self.dumpTextLocked(alloc, sel); } @@ -2033,15 +2063,15 @@ pub fn dumpTextLocked( /// Returns true if the terminal has a selection. pub fn hasSelection(self: *const Surface) bool { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); return self.io.terminal.screens.active.selection != null; } /// Returns the selected text. This is allocated. pub fn selectionString(self: *Surface, alloc: Allocator) !?[:0]const u8 { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); const sel = self.io.terminal.screens.active.selection orelse return null; return try self.io.terminal.screens.active.selectionString(alloc, .{ .sel = sel, @@ -2056,8 +2086,8 @@ pub fn pwd( self: *const Surface, alloc: Allocator, ) Allocator.Error!?[]const u8 { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); const terminal_pwd = self.io.terminal.getPwd() orelse return null; return try alloc.dupe(u8, terminal_pwd); } @@ -2074,7 +2104,7 @@ fn resolvePathForOpening( const resolved = try std.fs.path.resolve(self.alloc, &.{ terminal_pwd, path }); - std.fs.accessAbsolute(resolved, .{}) catch { + std.Io.Dir.accessAbsolute(global.io(), resolved, .{}) catch { self.alloc.free(resolved); return null; }; @@ -2088,10 +2118,10 @@ fn resolvePathForOpening( /// Returns the x/y coordinate of where the IME (Input Method Editor) /// keyboard should be rendered. pub fn imePoint(self: *const Surface) apprt.IMEPos { - self.renderer_state.mutex.lock(); + self.renderer_state.mutex.lockUncancelable(global.io()); const cursor = self.renderer_state.terminal.screens.active.cursor; const preedit_width: usize = if (self.renderer_state.preedit) |preedit| preedit.width() else 0; - self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.unlock(global.io()); // TODO: need to handle when scrolling and the cursor is not // in the visible portion of the screen. @@ -2165,34 +2195,18 @@ fn clipboardWrite(self: *const Surface, data: []const u8, loc: apprt.Clipboard) return; } - const dec = std.base64.standard.Decoder; - - // Build buffer - const size = dec.calcSizeForSlice(data) catch |err| switch (err) { - error.InvalidPadding => { - log.info("application sent invalid base64 data for OSC 52", .{}); - return; - }, - - // Should not be reachable but don't want to risk it. - else => return, - }; - var buf = try self.alloc.allocSentinel(u8, size, 0); + // Decode with the SIMD decoder, strict per the Kitty clipboard + // spec that also governs OSC 52 base64 handling: a request with + // characters outside the base64 alphabet is discarded entirely + // (never partially decoded), while a missing-padding tail is + // tolerated since OSC 52 has no way to report errors. + var buf = try self.alloc.allocSentinel(u8, simd.base64.maxLen(data), 0); defer self.alloc.free(buf); - buf[buf.len] = 0; - - // Decode - dec.decode(buf, data) catch |err| switch (err) { - // Ignore this. It is possible to actually have valid data and - // get this error, so we allow it. - error.InvalidPadding => {}, - - else => { - log.info("application sent invalid base64 data for OSC 52", .{}); - return; - }, + const decoded = simd.base64.decodeStrict(data, buf, .optional) catch { + log.info("application sent invalid base64 data for OSC 52", .{}); + return; }; - assert(buf[buf.len] == 0); + buf[decoded.len] = 0; // When clipboard-write is "ask" a prompt is displayed to the user asking // them to confirm the clipboard access. Each app runtime handles this @@ -2200,7 +2214,7 @@ fn clipboardWrite(self: *const Surface, data: []const u8, loc: apprt.Clipboard) const confirm = self.config.clipboard_write == .ask; self.rt_surface.setClipboard(loc, &.{.{ .mime = "text/plain", - .data = buf, + .data = buf[0..decoded.len :0], }}, confirm) catch |err| { log.err("error setting clipboard string err={}", .{err}); return; @@ -2232,13 +2246,14 @@ fn copySelectionToClipboards( const ScreenFormatter = terminal.formatter.ScreenFormatter; var aw: std.Io.Writer.Allocating = .init(alloc); - var contents: std.ArrayList(apprt.ClipboardContent) = .empty; + var contents_buf: [2]apprt.ClipboardContent = undefined; + var contents: std.ArrayList(apprt.ClipboardContent) = .initBuffer(&contents_buf); switch (format) { .plain => { var formatter: ScreenFormatter = .init(self.io.terminal.screens.active, opts); formatter.content = .{ .selection = sel }; try formatter.format(&aw.writer); - try contents.append(alloc, .{ + contents.appendAssumeCapacity(.{ .mime = "text/plain", .data = try aw.toOwnedSliceSentinel(0), }); @@ -2255,7 +2270,7 @@ fn copySelectionToClipboards( // Note: We don't apply codepoint mappings to VT format since it contains // escape sequences that should be preserved as-is - try contents.append(alloc, .{ + contents.appendAssumeCapacity(.{ .mime = "text/plain", .data = try aw.toOwnedSliceSentinel(0), }); @@ -2272,7 +2287,7 @@ fn copySelectionToClipboards( // Note: We don't apply codepoint mappings to HTML format since HTML // has its own character encoding and entity system - try contents.append(alloc, .{ + contents.appendAssumeCapacity(.{ .mime = "text/html", .data = try aw.toOwnedSliceSentinel(0), }); @@ -2283,7 +2298,7 @@ fn copySelectionToClipboards( var formatter: ScreenFormatter = .init(self.io.terminal.screens.active, opts); formatter.content = .{ .selection = sel }; try formatter.format(&aw.writer); - try contents.append(alloc, .{ + contents.appendAssumeCapacity(.{ .mime = "text/plain", .data = try aw.toOwnedSliceSentinel(0), }); @@ -2306,7 +2321,7 @@ fn copySelectionToClipboards( try formatter.format(&aw.writer); // Note: We don't apply codepoint mappings to HTML format - try contents.append(alloc, .{ + contents.appendAssumeCapacity(.{ .mime = "text/html", .data = try aw.toOwnedSliceSentinel(0), }); @@ -2363,31 +2378,29 @@ fn setSelection(self: *Surface, sel_: ?terminal.Selection) !void { fn setSelectionAndCopy(self: *Surface, sel: terminal.Selection) !void { try self.setSelection(sel); - // If copy on select is false then exit early. - if (self.config.copy_on_select == .false) return; - switch (self.config.copy_on_select) { - .false => unreachable, // handled above with an early exit + .none => {}, - // Both standard and selection clipboards are set. + // The selection clipboard is set if supported, otherwise nothing is copied. + .primary => try self.copySelectionToClipboards( + sel, + &.{.selection}, + .mixed, + ), + + // Only the standard clipboard is set. .clipboard => try self.copySelectionToClipboards( sel, - &.{ .standard, .selection }, + &.{.standard}, .mixed, ), - // The selection clipboard is set if supported, otherwise the standard. - .true => { - const clipboard: apprt.Clipboard = if (self.rt_surface.supportsClipboard(.selection)) - .selection - else - .standard; - try self.copySelectionToClipboards( - sel, - &.{clipboard}, - .mixed, - ); - }, + // Both standard and selection clipboards are set. + .both => try self.copySelectionToClipboards( + sel, + &.{ .standard, .selection }, + .mixed, + ), } } @@ -2440,7 +2453,7 @@ pub fn setFontSize(self: *Surface, size: font.face.DesiredSize) !void { // Notify our render thread of the new font stack. The renderer // MUST accept the new font grid and deref the old. - _ = self.renderer_thread.mailbox.push(.{ + _ = self.renderer_thread.mailbox.push(global.io(), .{ .font_grid = .{ .grid = font_grid, .set = &self.app.font_grid_set, @@ -2530,8 +2543,8 @@ pub fn preeditCallback(self: *Surface, preedit_: ?[]const u8) !void { crash.sentry.thread_state = self.crashThreadState(); defer crash.sentry.thread_state = null; - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); // We clear our selection when ANY OF: // 1. We have an existing preedit @@ -2566,7 +2579,7 @@ pub fn preeditCallback(self: *Surface, preedit_: ?[]const u8) !void { // Allocate the codepoints slice const Codepoint = rendererpkg.State.Preedit.Codepoint; - var codepoints: std.ArrayListUnmanaged(Codepoint) = .{}; + var codepoints: std.ArrayList(Codepoint) = .empty; defer codepoints.deinit(self.alloc); while (it.nextCodepoint()) |cp| { const width: usize = @intCast(unicode.table.get(cp).width); @@ -2697,8 +2710,8 @@ pub fn keyCallback( )) |v| return v; // If we allow KAM and KAM is enabled then we do nothing. if (self.config.vt_kam_allowed) { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); if (self.io.terminal.modes.get(.disable_keyboard)) return .consumed; } @@ -2728,8 +2741,8 @@ pub fn keyCallback( { // Refresh our link state const pos = self.rt_surface.getCursorPos() catch break :mouse_mods; - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); self.mouseRefreshLinks( pos, self.posToViewport(pos.x, pos.y), @@ -2802,11 +2815,11 @@ pub fn keyCallback( // an encoded value, we close the surface. We want to eventually // move this behavior to the apprt probably. if (self.child_exited) { + write_req.deinit(); self.close(); return .closed; } - errdefer write_req.deinit(); self.queueIo(switch (write_req) { .small => |v| .{ .write_small = v }, .stable => |v| .{ .write_stable = v }, @@ -2821,8 +2834,8 @@ pub fn keyCallback( // some data to send to the pty, then we move the viewport down to the // bottom. We also clear the selection for any key other then modifiers. if (!event.key.modifier()) { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); if (self.config.selection_clear_on_typing or event.key == .escape) @@ -2924,16 +2937,19 @@ fn maybeHandleBinding( // Determine if this entry has an action or if its a leader key. const leaf: input.Binding.Set.GenericLeaf = switch (entry.value_ptr.*) { .leader => |set| { - // Setup the next set we'll look at. - self.keyboard.sequence_set = set; - // Store this event so that we can drain and encode on invalid. // We don't need to cap this because it is naturally capped by // the config validation. if (try self.encodeKey(event, insp_ev)) |req| { - try self.keyboard.sequence_queued.append(self.alloc, req); + self.keyboard.sequence_queued.append(self.alloc, req) catch |err| { + req.deinit(); + return err; + }; } + // Setup the next set we'll look at only after all fallible work. + self.keyboard.sequence_set = set; + // Start or continue our key sequence _ = self.rt_app.performAction( .{ .surface = self }, @@ -3222,17 +3238,15 @@ fn encodeKey( ); defer alloc_writer.deinit(); - // This results in a double allocation but this is such an unlikely - // path the performance impact is unimportant. try input.key_encode.encode( &alloc_writer.writer, event, encoding_opts, ); - break :req try termio.Message.WriteReq.init( - self.alloc, - alloc_writer.writer.buffered(), - ); + break :req .{ .alloc = .{ + .alloc = self.alloc, + .data = try alloc_writer.toOwnedSlice(), + } }; }; // Copy the encoded data into the inspector event if we have one. @@ -3253,8 +3267,8 @@ fn encodeKey( } fn encodeKeyOpts(self: *const Surface) input.key_encode.Options { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); const t = &self.io.terminal; var opts: input.key_encode.Options = .fromTerminal(t); @@ -3295,9 +3309,27 @@ pub fn occlusionCallback(self: *Surface, visible: bool) !void { crash.sentry.thread_state = self.crashThreadState(); defer crash.sentry.thread_state = null; - _ = self.renderer_thread.mailbox.push(.{ + // Avoid duplicate renderer and visibility reports. + if (self.visible == visible) return; + self.visible = visible; + + // Update the terminal state for synchronous queries, then notify the IO + // thread so it can emit a mode 2033 report when enabled. + self.renderer_state.mutex.lockUncancelable(global.io()); + self.io.terminal.flags.visible = visible; + const report_visibility = self.io.terminal.modes.get(.report_visibility); + self.renderer_state.mutex.unlock(global.io()); + if (report_visibility) { + self.queueIo(.{ .visibility_report = .{ + .visible = visible, + .force = false, + } }, .unlocked); + } + + _ = self.renderer_thread.mailbox.push(global.io(), .{ .visible = visible, }, .{ .forever = {} }); + try self.queueRender(); } @@ -3315,7 +3347,7 @@ pub fn focusCallback(self: *Surface, focused: bool) !void { self.focused = focused; // Notify our render thread of the new state - _ = self.renderer_thread.mailbox.push(.{ + _ = self.renderer_thread.mailbox.push(global.io(), .{ .focus = focused, }, .{ .forever = {} }); @@ -3382,9 +3414,9 @@ pub fn focusCallback(self: *Surface, focused: bool) !void { // Update the focus state and notify the terminal { - self.renderer_state.mutex.lock(); + self.renderer_state.mutex.lockUncancelable(global.io()); self.io.terminal.flags.focused = focused; - self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.unlock(global.io()); self.queueIo(.{ .focused = focused }, .unlocked); } } @@ -3516,8 +3548,8 @@ pub fn scrollCallback( // log.info("SCROLL: delta_y={} delta_x={}", .{ y.delta, x.delta }); { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); // If we have an active mouse reporting mode, clear the selection. // The selection can occur if the user uses the shift mod key to @@ -3642,6 +3674,12 @@ fn isMouseReporting(self: *const Surface) bool { self.io.terminal.flags.mouse_event != .none; } +pub fn mouseReportingActive(self: *Surface) bool { + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); + return self.isMouseReporting(); +} + fn mouseReport( self: *Surface, button: ?input.MouseButton, @@ -3717,8 +3755,8 @@ fn mouseShiftCapture(self: *const Surface, lock: bool) bool { .false, .true => {}, } - if (lock) self.renderer_state.mutex.lock(); - defer if (lock) self.renderer_state.mutex.unlock(); + if (lock) self.renderer_state.mutex.lockUncancelable(global.io()); + defer if (lock) self.renderer_state.mutex.unlock(global.io()); // If the terminal explicitly requests it then we always allow it // since we processed never/always at this point. @@ -3739,8 +3777,8 @@ fn mouseShiftCapture(self: *const Surface, lock: bool) bool { /// Returns true if the mouse is currently captured by the terminal /// (i.e. reporting events). pub fn mouseCaptured(self: *Surface) bool { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); return self.io.terminal.flags.mouse_event != .none; } @@ -3796,21 +3834,12 @@ pub fn mouseButtonCallback( // If we are within the interval that the click would register // an increment then we do not extend the selection. - if (std.time.Instant.now()) |now| { - const click_time = self.mouse.selection_gesture.left_click_time orelse - break :extend_selection; - const since = now.since(click_time); - if (since <= self.config.mouse_interval) { - // Click interval very short, we may be increasing - // click counts so we don't extend the selection. - break :extend_selection; - } - } else |err| { - // This is a weird behavior, I think either behavior is actually - // fine. This failure should be exceptionally rare anyways. - // My thinking here is that we can't be sure if we should extend - // the selection or not so we just don't. - log.warn("failed to get time, not extending selection err={}", .{err}); + const click_time = self.mouse.selection_gesture.left_click_time orelse + break :extend_selection; + const since = click_time.untilNow(global.io(), .awake); + if (since.toNanoseconds() <= self.config.mouse_interval) { + // Click interval very short, we may be increasing + // click counts so we don't extend the selection. break :extend_selection; } @@ -3821,8 +3850,8 @@ pub fn mouseButtonCallback( } if (button == .left and action == .release) { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); // The selection gesture tracks whether a press became a drag by // comparing the release cell to the original press cell. Resolve the @@ -3860,7 +3889,7 @@ pub fn mouseButtonCallback( // 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) { + if (self.config.copy_on_select != .none) { const prev_ = self.io.terminal.screens.active.selection; if (prev_) |prev| { try self.setSelectionAndCopy(terminal.Selection.init( @@ -3897,8 +3926,8 @@ pub fn mouseButtonCallback( // Report mouse events if enabled { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); if (self.isMouseReporting()) report: { // If we have shift-pressed and we aren't allowed to capture it, // then we do not do a mouse report. @@ -3937,8 +3966,8 @@ pub fn mouseButtonCallback( // For left button clicks we always record some information for // selection/highlighting purposes. if (button == .left and action == .press) click: { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); const t: *terminal.Terminal = self.renderer_state.terminal; const screen: *terminal.Screen = self.renderer_state.terminal.screens.active; @@ -3962,12 +3991,8 @@ pub fn mouseButtonCallback( break :pin pin; }; - const time = std.time.Instant.now() catch |err| time: { - log.err("error reading time, mouse multi-click won't work err={}", .{err}); - break :time null; - }; var press_selection = try self.mouse.selection_gesture.press(t, .{ - .time = time, + .time = std.Io.Timestamp.now(global.io(), .awake), .pin = pin, .xpos = pos.x, .ypos = pos.y, @@ -4022,22 +4047,16 @@ pub fn mouseButtonCallback( } } - // Middle-click paste source follows copy-on-select: when copy-on-select - // targets the selection clipboard, middle-click reads from it; when - // copy-on-select targets the system clipboard, middle-click reads from - // that instead. Falls back to the standard clipboard on platforms that - // do not support the selection clipboard. + // Middle-click action, either ignore, or paste from clipboard or paste from the selection clipboard if supported. if (button == .middle and action == .press) switch (self.config.middle_click_action) { .ignore => {}, + .@"clipboard-paste" => { + _ = try self.startClipboardRequest(.standard, .{ .paste = .standard }); + }, .@"primary-paste" => { - const clipboard: apprt.Clipboard = switch (self.config.copy_on_select) { - .clipboard => .standard, - .true, .false => if (self.rt_surface.supportsClipboard(.selection)) - .selection - else - .standard, - }; - _ = try self.startClipboardRequest(clipboard, .{ .paste = {} }); + if (self.rt_surface.supportsClipboard(.selection)) { + _ = try self.startClipboardRequest(.selection, .{ .paste = .selection }); + } }, }; @@ -4047,8 +4066,8 @@ pub fn mouseButtonCallback( // want to be careful in the future we can add a function to apprts // that let's us know. if (button == .right and action == .press) sel: { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); // Get our viewport pin const screen: *terminal.Screen = self.renderer_state.terminal.screens.active; @@ -4119,9 +4138,9 @@ pub fn mouseButtonCallback( } else { // Pasting can trigger a lock grab in complete clipboard // request so we need to unlock. - self.renderer_state.mutex.unlock(); - defer self.renderer_state.mutex.lock(); - _ = try self.startClipboardRequest(.standard, .paste); + self.renderer_state.mutex.unlock(global.io()); + defer self.renderer_state.mutex.lockUncancelable(global.io()); + _ = try self.startClipboardRequest(.standard, .{ .paste = .standard }); // We don't need to clear selection because we didn't have // one to begin with. @@ -4134,9 +4153,9 @@ pub fn mouseButtonCallback( // Pasting can trigger a lock grab in complete clipboard // request so we need to unlock. - self.renderer_state.mutex.unlock(); - defer self.renderer_state.mutex.lock(); - _ = try self.startClipboardRequest(.standard, .paste); + self.renderer_state.mutex.unlock(global.io()); + defer self.renderer_state.mutex.lockUncancelable(global.io()); + _ = try self.startClipboardRequest(.standard, .{ .paste = .standard }); }, } @@ -4295,7 +4314,9 @@ fn linkAtPos( const mouse_mods = self.mouseModsWithCapture(self.mouse.mods); // If we have the proper modifiers set then we can check for OSC8 links. - if (mouse_mods.equal(input.ctrlOrSuper(.{}))) hyperlink: { + if (self.config.link_osc8 and + mouse_mods.equal(input.ctrlOrSuper(.{}))) + hyperlink: { const rac = mouse_pin.rowAndCell(); const cell = rac.cell; if (!cell.hyperlink) break :hyperlink; @@ -4329,12 +4350,10 @@ fn linkAtPin( .semantic_prompt_boundary = true, }) orelse return null; - var strmap: terminal.StringMap = undefined; - self.alloc.free(try screen.selectionString(self.alloc, .{ + const strmap = try screen.selectionStringMap(self.alloc, .{ .sel = line, .trim = false, - .map = &strmap, - })); + }); defer strmap.deinit(self.alloc); for (self.config.links) |link| { @@ -4405,7 +4424,7 @@ fn processLinks(self: *Surface, pos: apprt.CursorPos) !bool { log.warn("failed to get URI for OSC8 hyperlink", .{}); return false; }; - try self.openUrl(.{ .kind = .unknown, .url = uri }); + try self.openUrl(.{ .kind = .osc8, .url = uri }); }, } @@ -4428,7 +4447,6 @@ fn openUrl( // apprts to handle this themselves. log.warn("apprt did not handle open URL action, falling back to default opener", .{}); try internal_os.open( - self.alloc, action.kind, action.url, ); @@ -4474,8 +4492,8 @@ pub fn mousePressureCallback( if (self.mouse.click_state[left_idx] == .press and stage == .deep) select: { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); const sel = self.mouse.selection_gesture.deepPress( self.renderer_state.terminal, @@ -4540,8 +4558,8 @@ pub fn cursorPosCallback( try self.queueRender(); } - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); // No mouse point so we don't highlight links self.renderer_state.mouse.point = null; @@ -4567,8 +4585,8 @@ pub fn cursorPosCallback( self.mouse.over_link = false; // We are reading/writing state for the remainder - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); // Update our mouse state. We set this to null initially because we only // want to set it when we're not selecting or doing any other mouse @@ -4824,8 +4842,8 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool // CSI/ESC triggers a scroll. { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); self.scrollToBottom() catch |err| { log.warn("error scrolling to bottom err={}", .{err}); }; @@ -4833,10 +4851,10 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool }, .text => |data| { - // For text we always allocate just because its easier to - // handle all cases that way. - const buf = try self.alloc.alloc(u8, data.len); - defer self.alloc.free(buf); + var stack = std.heap.stackFallback(256, self.alloc); + const alloc = stack.get(); + const buf = try alloc.alloc(u8, data.len); + defer alloc.free(buf); const text = configpkg.string.parse(buf, data) catch |err| { log.warn( "error parsing text binding text={s} err={}", @@ -4851,8 +4869,8 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool // Text triggers a scroll. { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); self.scrollToBottom() catch |err| { log.warn("error scrolling to bottom err={}", .{err}); }; @@ -4864,8 +4882,8 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool // in cursor keys mode. We're in "normal" mode if cursor // keys mode is NOT set. const normal = normal: { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); // With the lock held, we must scroll to the bottom. // We always scroll to the bottom for these inputs. @@ -4884,8 +4902,8 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool }, .reset => { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); self.renderer_state.terminal.fullReset(); }, @@ -4955,7 +4973,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool terminal.search.Thread.threadMain, .{&s.state}, ); - s.thread.setName("search") catch {}; + s.thread.setName(global.io(), "search") catch {}; break :init s; }; @@ -4968,6 +4986,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool } _ = s.state.mailbox.push( + global.io(), .{ .change_needle = try .init( self.alloc, text, @@ -4980,6 +4999,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .navigate_search => |nav| { const s: *Search = if (self.search) |*s| s else return false; _ = s.state.mailbox.push( + global.io(), .{ .select = switch (nav) { .next => .next, .previous => .prev, @@ -4990,8 +5010,8 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool }, .copy_to_clipboard => |format| { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); if (self.io.terminal.screens.active.selection) |sel| { try self.copySelectionToClipboards( @@ -5022,8 +5042,8 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool if (!self.mouse.over_link) return false; const pos = try self.rt_surface.getCursorPos(); - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); if (try self.linkAtPos(pos)) |link_info| { const url_text = switch (link_info.action) { .open => url_text: { @@ -5068,15 +5088,15 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool {}, ), - .paste_from_clipboard => return try self.startClipboardRequest( + .paste_from_clipboard => return (try self.startClipboardRequest( .standard, - .{ .paste = {} }, - ), + .{ .paste = .standard }, + )) == .started, - .paste_from_selection => return try self.startClipboardRequest( + .paste_from_selection => return (try self.startClipboardRequest( .selection, - .{ .paste = {} }, - ), + .{ .paste = .selection }, + )) == .started, .increase_font_size => |delta| { // Max delta is somewhat arbitrary. @@ -5141,6 +5161,12 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .tab, ), + .prompt_window_title => return try self.rt_app.performAction( + .{ .surface = self }, + .prompt_title, + .window, + ), + .set_surface_title => |v| { const title = try self.alloc.dupeZ(u8, v); defer self.alloc.free(title); @@ -5161,6 +5187,16 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool ); }, + .set_window_title => |v| { + const title = try self.alloc.dupeZ(u8, v); + defer self.alloc.free(title); + return try self.rt_app.performAction( + .{ .surface = self }, + .set_window_title, + .{ .title = title }, + ); + }, + .clear_screen => { // This is a duplicate of some of the logic in termio.clearScreen // but we need to do this here so we can know the answer before @@ -5168,8 +5204,8 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool // alternate screen then clear screen does nothing so we want to // return false so the keybind can be unconsumed. { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); if (self.io.terminal.screens.active_key == .alternate) return false; } @@ -5192,8 +5228,8 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .scroll_to_row => |n| { { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); const t: *terminal.Terminal = self.renderer_state.terminal; t.screens.active.scroll(.{ .row = n }); } @@ -5203,8 +5239,8 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .scroll_to_selection => { { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); const sel = self.io.terminal.screens.active.selection orelse return false; const tl = sel.topLeft(self.io.terminal.screens.active); self.io.terminal.screens.active.scroll(.{ .pin = tl }); @@ -5300,6 +5336,12 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .{ .amount = position }, ), + .move_tab_to_new_window => return try self.rt_app.performAction( + .{ .surface = self }, + .move_tab_to_new_window, + {}, + ), + .new_split => |direction| return try self.rt_app.performAction( .{ .surface = self }, .new_split, @@ -5442,8 +5484,8 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool ), .select_all => { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); const sel = self.io.terminal.screens.active.selectAll(); if (sel) |s| { @@ -5564,7 +5606,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .main => @panic("crash binding action, crashing intentionally"), .render => { - _ = self.renderer_thread.mailbox.push(.{ .crash = {} }, .{ .forever = {} }); + _ = self.renderer_thread.mailbox.push(global.io(), .{ .crash = {} }, .{ .forever = {} }); self.queueRender() catch |err| { // Not a big deal if this fails. log.warn("failed to notify renderer of crash message err={}", .{err}); @@ -5575,8 +5617,8 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool }, .adjust_selection => |direction| { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); const screen: *terminal.Screen = self.io.terminal.screens.active; const sel = if (screen.selection) |*sel| sel else { @@ -5655,7 +5697,8 @@ fn writeScreenFile( ) !void { // Create a temporary directory to store our scrollback. var tmp_dir = try internal_os.TempDir.init(); - errdefer tmp_dir.deinit(); + var retain_tmp_dir = false; + defer if (retain_tmp_dir) tmp_dir.close(.retain) else tmp_dir.deinit(); var filename_buf: [std.fs.max_path_bytes]u8 = undefined; const filename = try std.fmt.bufPrint( @@ -5672,23 +5715,24 @@ fn writeScreenFile( // Open our scrollback file var file = try tmp_dir.dir.createFile( + global.io(), filename, switch (builtin.os.tag) { .windows => .{}, - else => .{ .mode = 0o600 }, + else => .{ .permissions = .fromMode(0o600) }, }, ); - defer file.close(); + defer file.close(global.io()); // Screen.dumpString writes byte-by-byte, so buffer it var buf: [4096]u8 = undefined; - var file_writer = file.writer(&buf); + var file_writer = file.writer(global.io(), &buf); var buf_writer = &file_writer.interface; // Write the scrollback contents. This requires a lock. { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); // We only dump history if we have history. We still keep // the file and write the empty file to the pty so that this @@ -5724,7 +5768,6 @@ fn writeScreenFile( const sel = sel_ orelse { // If we have no selection we have no data so we do nothing. - tmp_dir.deinit(); return; }; @@ -5751,7 +5794,11 @@ fn writeScreenFile( // Get the final path var path_buf: [std.fs.max_path_bytes]u8 = undefined; - const path = try tmp_dir.dir.realpath(filename, &path_buf); + const path = path_buf[0..try tmp_dir.dir.realPathFile( + global.io(), + filename, + &path_buf, + )]; switch (write_screen.action) { .copy => { @@ -5774,71 +5821,294 @@ fn writeScreenFile( path, ), .unlocked), } + + // The action accepted the path, so retain the file for its consumer. + retain_tmp_dir = true; } +/// The payload for completing a clipboard request with +/// completeClipboardRequest. +pub const CompleteClipboard = struct { + /// The representations the apprt could serve for the request's MIME + /// types. These are immediately copied as needed so they only need + /// to live for the duration of the completion call. Requesters that + /// only carry text (paste, OSC 52) use the first text-like + /// representation. + contents: []const terminal.clipboard.Content = &.{}, + + /// The listing of MIME types available on the clipboard, only + /// gathered when the request asked for it. + available: []const []const u8 = &.{}, + + /// True if any clipboard confirmation prompt was already answered + /// by the user, skipping further prompts: + /// + /// - For "regular" pasting this means that unsafe pastes are + /// allowed. Unsafe data is defined as data that contains + /// newlines, though this definition may change later to detect + /// other scenarios. + /// + /// - For OSC 52 and Kitty clipboard protocol reads and writes no + /// prompt is shown to the user when this is true. + confirmed: bool = false, + + /// True if the user asked to remember their decision. This is only + /// honored by request types that support session grants (Kitty + /// clipboard protocol requests carrying a password). + remember: bool = false, +}; + /// Call this to complete a clipboard request sent to apprt. This should -/// only be called once for each request. The data is immediately copied so -/// it is safe to free the data after this call. -/// -/// If `confirmed` is true then any clipboard confirmation prompts are skipped: -/// -/// - For "regular" pasting this means that unsafe pastes are allowed. Unsafe -/// data is defined as data that contains newlines, though this definition -/// may change later to detect other scenarios. -/// -/// - For OSC 52 reads and writes no prompt is shown to the user if -/// `confirmed` is true. +/// only be called once for each request. /// -/// If `confirmed` is false then this may return either an UnsafePaste or -/// UnauthorizedPaste error, depending on the type of clipboard request. +/// If `complete.confirmed` is false then this may return either an +/// UnsafePaste or UnauthorizedPaste error, depending on the type of +/// clipboard request. The request state remains alive in that case so +/// the apprt can run its confirmation flow. pub fn completeClipboardRequest( self: *Surface, req: apprt.ClipboardRequest, - data: [:0]const u8, - confirmed: bool, + complete: CompleteClipboard, ) !void { switch (req) { - .paste => try self.completeClipboardPaste(data, confirmed), + .paste => try self.completeClipboardPaste( + clipboardTextContent(complete.contents) orelse "", + complete.confirmed, + ), + + .list => |clipboard| if (!try self.completeClipboardPasteEvent( + clipboard, + complete.available, + )) { + log.debug("mode 5522 paste event was not written", .{}); + }, .osc_52_read => |clipboard| try self.completeClipboardReadOSC52( - data, + clipboardTextContent(complete.contents) orelse "", clipboard, - confirmed, + complete.confirmed, ), - .osc_52_write => |clipboard| try self.rt_surface.setClipboard(clipboard, &.{.{ - .mime = "text/plain", - .data = data, - }}, !confirmed), + .osc_52_write => |clipboard| { + // The write API wants sentinel-terminated data; the write + // text round-tripped through the apprt confirmation flow as + // a plain representation. + const data = try self.alloc.dupeZ( + u8, + clipboardTextContent(complete.contents) orelse "", + ); + defer self.alloc.free(data); + try self.rt_surface.setClipboard(clipboard, &.{.{ + .mime = "text/plain", + .data = data, + }}, !complete.confirmed); + }, + + .kitty_read => |kitty| { + // If we need confirmation we return an error without + // consuming the request state; the apprt keeps it alive + // for the confirmation flow. A session grant carried by + // the request skips the prompt, and a request with no + // data types is exempt from prompting entirely; see + // readPromptExempt. + if (self.config.clipboard_read == .ask and + !complete.confirmed and + !kitty.granted and + !terminal.kitty.clipboard.readPromptExempt(kitty.mimes.len)) + { + return error.UnauthorizedPaste; + } + + // Past the confirmation check the request is consumed: + // every path from here, including errors, must destroy it. + defer kitty.destroy(); + + // Record a session grant when the user asked to remember + // their decision and the request carried a usable + // password. The grants live with the terminal state on + // the IO thread. + if (complete.remember and kitty.pw.len > 0) { + const pw = try self.alloc.dupe(u8, kitty.pw); + self.queueIo(.{ .kitty_clipboard_grant_read = .{ + .alloc = self.alloc, + .pw = pw, + } }, .unlocked); + } + + try self.completeKittyClipboardRead( + kitty, + complete.contents, + complete.available, + ); + }, + + .kitty_write => |kitty| { + // If we need confirmation we return an error without + // consuming the request state; the apprt keeps it alive + // for the confirmation flow. A session grant carried by + // the request skips the prompt. + if (self.config.clipboard_write == .ask and + !complete.confirmed and + !kitty.granted) + { + return error.UnauthorizedPaste; + } + + // Past the confirmation check the request is consumed: + // every path from here, including errors, must destroy it. + defer kitty.destroy(); + + // Record a session grant when the user asked to remember + // their decision and the request carried a usable + // password. The grants live with the terminal state on + // the IO thread. + if (complete.remember and kitty.pw.len > 0) { + const pw = try self.alloc.dupe(u8, kitty.pw); + self.queueIo(.{ .kitty_clipboard_grant_write = .{ + .alloc = self.alloc, + .pw = pw, + } }, .unlocked); + } + + // Apply the committed representations carried by the + // request itself; any contents echoed back by the apprt + // are only what its confirmation prompt displayed. An + // empty commit clears the clipboard, which the apprt + // write API expresses as a single empty text entry. + self.rt_surface.setClipboard( + kitty.location, + if (kitty.contents.len > 0) kitty.contents else &.{.{ + .mime = "text/plain", + .data = "", + }}, + false, + ) catch |err| { + log.err("error setting clipboard err={}", .{err}); + try self.kittyClipboardStatus( + .write, + kitty.id, + kitty.terminator, + .EIO, + ); + return; + }; + + try self.kittyClipboardStatus( + .write, + kitty.id, + kitty.terminator, + .DONE, + ); + }, + } +} + +/// The first text-like representation of the contents, if any. +fn clipboardTextContent(contents: []const terminal.clipboard.Content) ?[]const u8 { + for (contents) |content| { + if (terminal.clipboard.isTextMime(content.mime)) return content.data; + } + return null; +} + +/// Deny an in-flight clipboard request. This consumes the request: for +/// request types whose protocol expects an answer, the denial reply is +/// written to the pty. +pub fn denyClipboardRequest(self: *Surface, req: apprt.ClipboardRequest) void { + switch (req) { + // A denied paste simply doesn't happen. + .paste, .list => {}, + + // OSC 52 has no error responses, but the client is waiting on + // a reply, so a denied read is answered with empty contents. + .osc_52_read => |clipboard| self.completeClipboardReadOSC52( + "", + clipboard, + true, + ) catch |err| { + log.warn("error replying to OSC 52 clipboard read err={}", .{err}); + }, + + // A denied write simply doesn't happen. + .osc_52_write => {}, + + // The Kitty clipboard protocol reports denial explicitly. + .kitty_read => |kitty| { + defer kitty.destroy(); + self.kittyClipboardStatus( + .read, + kitty.id, + kitty.terminator, + .EPERM, + ) catch |err| { + log.warn("error replying to kitty clipboard read err={}", .{err}); + }; + }, + + .kitty_write => |kitty| { + defer kitty.destroy(); + self.kittyClipboardStatus( + .write, + kitty.id, + kitty.terminator, + .EPERM, + ) catch |err| { + log.warn("error replying to kitty clipboard write err={}", .{err}); + }; + }, } } /// This starts a clipboard request, with some basic validation. For example, /// an OSC 52 request is not actually requested if OSC 52 is disabled. /// -/// Returns true if the request was started, false if it was not (e.g., clipboard -/// doesn't contain text for paste requests). This allows performable keybinds -/// to pass through when the action cannot be performed. +/// The result reports whether the request was started; requests that +/// weren't started never complete. Callers own reacting to that, e.g. +/// performable paste keybinds pass through and Kitty clipboard reads +/// answer the program. fn startClipboardRequest( self: *Surface, loc: apprt.Clipboard, req: apprt.ClipboardRequest, -) !bool { - switch (req) { - .paste => {}, // always allowed +) !apprt.ClipboardReadResult { + const effective_req: apprt.ClipboardRequest = switch (req) { + .paste => |clipboard| effective: { + // Snapshot the mode before asking the apprt for clipboard data. + // Event pastes request only a MIME listing, while ordinary + // pastes request the text representation as before. + self.renderer_state.mutex.lockUncancelable(global.io()); + const event = self.io.terminal.modes.get(.kitty_paste_events); + self.renderer_state.mutex.unlock(global.io()); + + break :effective if (event) + .{ .list = clipboard } + else + req; + }, + else => req, + }; + + switch (effective_req) { + .paste, .list => {}, // always allowed .osc_52_read => if (self.config.clipboard_read == .deny) { log.info( "application attempted to read clipboard, but 'clipboard-read' is set to deny", .{}, ); - return false; + return .unsupported; }, - // No clipboard write code paths travel through this function + // The clipboard access policies were already applied by + // kittyClipboardRead and kittyClipboardWrite, which own + // replying on denial. + .kitty_read, .kitty_write => {}, + + // OSC 52 writes don't travel through this function; they go + // straight to the apprt setClipboard API. .osc_52_write => unreachable, } - return try self.rt_surface.clipboardRequest(loc, req); + return try self.rt_surface.clipboardRequest(loc, effective_req); } fn completeClipboardPaste( @@ -5849,8 +6119,8 @@ fn completeClipboardPaste( if (data.len == 0) return; const encode_opts: input.paste.Options = encode_opts: { - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); const opts: input.paste.Options = .fromTerminal(&self.io.terminal); // If we have paste protection enabled, we detect unsafe pastes and return @@ -5920,6 +6190,63 @@ fn completeClipboardPaste( }; } +/// Send a Kitty clipboard-protocol paste event when mode 5522 is enabled. +/// The event only lists the available MIME types; it does not read any of +/// their data. The shared terminal paste implementation generates and records +/// the one-time password used by the program's follow-up OSC 5522 read. +fn completeClipboardPasteEvent( + self: *Surface, + clipboard: apprt.Clipboard, + available: []const []const u8, +) !bool { + if (self.readonly) return false; + + const kitty_clipboard = terminal.kitty.clipboard; + const location: terminal.clipboard.Location = switch (clipboard) { + .standard => .standard, + .selection => .selection, + .primary => .primary, + }; + + // The protocol implementation caps listings at this size too. Cap here + // so the temporary Content array stays on the stack. + var contents_buf: [kitty_clipboard.max_listing_mimes]terminal.clipboard.Content = undefined; + const contents_len = @min(available.len, contents_buf.len); + for (available[0..contents_len], contents_buf[0..contents_len]) |mime, *content| { + content.* = .{ .mime = mime, .data = "" }; + } + + var aw: std.Io.Writer.Allocating = .init(self.alloc); + defer aw.deinit(); + + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); + + const pasted = try terminal.paste.paste(.{ + .terminal = &self.io.terminal, + .alloc = self.alloc, + .writer = &aw.writer, + .kitty_clipboard = .{ + .grants = &self.io.terminal_stream.handler.kitty_clipboard_grants, + .io = global.io(), + }, + }, .{ + .source = .{ .clipboard = location }, + .contents = .{ .memory = contents_buf[0..contents_len] }, + // A paste event discloses no clipboard data, so unsafe-text + // confirmation does not apply. If mode 5522 is reset, the empty + // stand-in representations cause the shared helper to write nothing. + .allow_unsafe = true, + }); + if (!pasted) return false; + + self.queueIo(.{ .write_alloc = .{ + .alloc = self.alloc, + .data = try aw.toOwnedSlice(), + } }, .locked); + return true; +} + fn completeClipboardReadOSC52( self: *Surface, data: []const u8, @@ -5940,8 +6267,8 @@ fn completeClipboardReadOSC52( // This must hold the base64 encoded data PLUS the OSC code surrounding it. const enc = std.base64.standard.Encoder; const size = enc.calcSize(data.len); - var buf = try self.alloc.alloc(u8, size + 9); // const for OSC - defer self.alloc.free(buf); + const buf = try self.alloc.alloc(u8, size + 9); // const for OSC + errdefer self.alloc.free(buf); const kind: u8 = switch (clipboard_type) { .standard => 'c', @@ -5959,10 +6286,177 @@ fn completeClipboardReadOSC52( const encoded = enc.encode(buf[prefix.len..], data); assert(encoded.len == size); - self.queueIo(try termio.Message.writeReq( - self.alloc, - buf, - ), .unlocked); + self.queueIo(.{ .write_alloc = .{ + .alloc = self.alloc, + .data = buf, + } }, .unlocked); +} + +/// Handle a Kitty clipboard protocol (OSC 5522) read request forwarded +/// by the IO thread. This takes ownership of the request state. +fn kittyClipboardRead( + self: *Surface, + req: *apprt.ClipboardRequest.KittyRead, +) !void { + // A read denied by policy answers EPERM so clients degrade + // gracefully instead of waiting on a response that never comes. + if (self.config.clipboard_read == .deny) { + defer req.destroy(); + log.info("application attempted to read clipboard, but 'clipboard-read' is set to deny", .{}); + try self.kittyClipboardStatus(.read, req.id, req.terminator, .EPERM); + return; + } + + const result = self.startClipboardRequest( + req.location, + .{ .kitty_read = req }, + ) catch |err| { + defer req.destroy(); + self.kittyClipboardStatus(.read, req.id, req.terminator, .EIO) catch {}; + return err; + }; + + switch (result) { + // The request completes asynchronously. + .started => {}, + + // The clipboard has nothing we can serve, which is a + // successful read that serves no representations. This never + // prompts even under an ask policy since there are no contents + // to disclose. + .unavailable => { + defer req.destroy(); + try self.completeKittyClipboardRead(req, &.{}, &.{}); + }, + + // The apprt can't serve this clipboard at all, e.g. an + // unsupported primary selection. + .unsupported => { + defer req.destroy(); + try self.kittyClipboardStatus(.read, req.id, req.terminator, .ENOSYS); + }, + } +} + +/// Handle a committed Kitty clipboard protocol (OSC 5522) write +/// transaction forwarded by the IO thread. This takes ownership of the +/// request state. +fn kittyClipboardWrite( + self: *Surface, + req: *apprt.ClipboardRequest.KittyWrite, +) !void { + // A write denied by policy answers EPERM so clients degrade + // gracefully instead of waiting on a response that never comes. + // The IO thread already fails transactions that begin under a + // deny policy, but the policy may have changed mid-transaction. + if (self.config.clipboard_write == .deny) { + defer req.destroy(); + log.info("application attempted to write clipboard, but 'clipboard-write' is set to deny", .{}); + try self.kittyClipboardStatus(.write, req.id, req.terminator, .EPERM); + return; + } + + const result = self.startClipboardRequest( + req.location, + .{ .kitty_write = req }, + ) catch |err| { + defer req.destroy(); + self.kittyClipboardStatus(.write, req.id, req.terminator, .EIO) catch {}; + return err; + }; + + switch (result) { + // The request completes asynchronously. + .started => {}, + + // The apprt can't write this clipboard at all, e.g. an + // unsupported primary selection. Writes carry their own + // contents so there is no meaningful unavailable state; treat + // it the same. + .unavailable, .unsupported => { + defer req.destroy(); + try self.kittyClipboardStatus(.write, req.id, req.terminator, .ENOSYS); + }, + } +} + +/// Reply to a Kitty clipboard request with a single status packet. +fn kittyClipboardStatus( + self: *Surface, + op: terminal.kitty.clipboard.Operation, + id: []const u8, + terminator: terminal.osc.Terminator, + status: terminal.kitty.clipboard.Status, +) error{ OutOfMemory, WriteFailed }!void { + var aw: std.Io.Writer.Allocating = .init(self.alloc); + defer aw.deinit(); + try (terminal.kitty.clipboard.Response{ + .op = op, + .status = status, + .id = id, + .terminator = terminator, + }).encode(&aw.writer); + + self.queueIo(.{ .write_alloc = .{ + .alloc = self.alloc, + .data = try aw.toOwnedSlice(), + } }, .unlocked); +} + +/// Complete a Kitty clipboard protocol read with the clipboard +/// contents. +fn completeKittyClipboardRead( + self: *Surface, + req: *const apprt.ClipboardRequest.KittyRead, + contents: []const terminal.clipboard.Content, + available: []const []const u8, +) !void { + const kitty_clipboard = terminal.kitty.clipboard; + + // Serve the requested representations in request order under their + // requested names. Text-like MIME aliases all match the canonical + // text representation, since that is the only name the apprt + // serves text under. Requested types without a representation are + // simply never served, which is how the protocol communicates an + // unavailable representation. + var contents_buf: [kitty_clipboard.max_read_mimes]terminal.clipboard.Content = undefined; + var contents_len: usize = 0; + for (req.mimes) |mime| { + const data: []const u8 = data: { + for (contents) |content| { + if (std.mem.eql(u8, content.mime, mime)) break :data content.data; + if (terminal.clipboard.isTextMime(mime) and + terminal.clipboard.isTextMime(content.mime)) + { + break :data content.data; + } + } + + continue; + }; + + contents_buf[contents_len] = .{ .mime = mime, .data = data }; + contents_len += 1; + } + + // Encode the full success sequence: the OK packet, the targets + // listing if it was requested, DATA chunks for each served + // representation, and the final DONE packet. + var aw: std.Io.Writer.Allocating = .init(self.alloc); + defer aw.deinit(); + try (kitty_clipboard.ReadSuccess{ + .primary = req.location == .primary, + .id = req.id, + .list = req.list, + .available = available, + .contents = contents_buf[0..contents_len], + .terminator = req.terminator, + }).encode(&aw.writer); + + self.queueIo(.{ .write_alloc = .{ + .alloc = self.alloc, + .data = try aw.toOwnedSlice(), + } }, .unlocked); } fn showDesktopNotification(self: *Surface, title: [:0]const u8, body: [:0]const u8) !void { @@ -5970,12 +6464,12 @@ fn showDesktopNotification(self: *Surface, title: [:0]const u8, body: [:0]const // how fast identical notifications can be sent sequentially. const hash_algorithm = std.hash.Wyhash; - const now = try std.time.Instant.now(); + const now: std.Io.Timestamp = .now(global.io(), .awake); // Set a limit of one desktop notification per second so that the OS // doesn't kill us when we run out of resources. if (self.app.last_notification_time) |last| { - if (now.since(last) < 1 * std.time.ns_per_s) { + if (last.durationTo(now).toSeconds() < 1) { log.warn("rate limiting desktop notifications", .{}); return; } @@ -5992,7 +6486,7 @@ fn showDesktopNotification(self: *Surface, title: [:0]const u8, body: [:0]const // notifications with identical content. if (self.app.last_notification_time) |last| { if (self.app.last_notification_digest == new_digest) { - if (now.since(last) < 5 * std.time.ns_per_s) { + if (last.durationTo(now).toSeconds() < 5) { log.warn("suppressing identical desktop notification", .{}); return; } @@ -6034,3 +6528,18 @@ fn presentSurface(self: *Surface) !void { pub fn getProcessInfo(self: *Surface, comptime info: ProcessInfo) ?ProcessInfo.Type(info) { return self.io.getProcessInfo(info); } + +test "queueIo frees allocated writes in readonly mode" { + const testing = std.testing; + + const surface = try testing.allocator.create(Surface); + defer testing.allocator.destroy(surface); + surface.readonly = true; + + // queueIo must free allocated writes in read-only mode. + const data = try testing.allocator.dupe(u8, "\x1b]lGhostty\x1b\\"); + surface.queueIo(.{ .write_alloc = .{ + .alloc = testing.allocator, + .data = data, + } }, .unlocked); +} diff --git a/vendor/libghostty-vt/src/apprt.zig b/vendor/libghostty-vt/src/apprt.zig index c467f180..22256cc9 100644 --- a/vendor/libghostty-vt/src/apprt.zig +++ b/vendor/libghostty-vt/src/apprt.zig @@ -27,6 +27,7 @@ pub const Target = action.Target; pub const ContentScale = structs.ContentScale; pub const Clipboard = structs.Clipboard; pub const ClipboardContent = structs.ClipboardContent; +pub const ClipboardReadResult = structs.ClipboardReadResult; pub const ClipboardRequest = structs.ClipboardRequest; pub const ClipboardRequestType = structs.ClipboardRequestType; pub const ColorScheme = structs.ColorScheme; @@ -51,6 +52,15 @@ pub const runtime = switch (build_config.artifact) { pub const App = runtime.App; pub const Surface = runtime.Surface; +/// True if all GPU operations (drawing, resource creation and +/// destruction) must happen on the app thread. This is the case +/// when the graphics context is owned by the app thread (GTK's +/// GLArea). When false, the render thread performs GPU operations +/// itself. +pub const must_draw_from_app_thread = + @hasDecl(App, "must_draw_from_app_thread") and + App.must_draw_from_app_thread; + test { _ = Runtime; _ = runtime; diff --git a/vendor/libghostty-vt/src/apprt/action.zig b/vendor/libghostty-vt/src/apprt/action.zig index 4728d73c..7e902e56 100644 --- a/vendor/libghostty-vt/src/apprt/action.zig +++ b/vendor/libghostty-vt/src/apprt/action.zig @@ -8,6 +8,7 @@ const renderer = @import("../renderer.zig"); const terminal = @import("../terminal/main.zig"); const CoreSurface = @import("../Surface.zig"); const lib = @import("../lib/main.zig"); +const compat_testing = @import("../lib/compat/testing.zig"); /// The target for an action. This is generally the thing that had focus /// while the action was made but the concept of "focus" is not guaranteed @@ -195,6 +196,9 @@ pub const Action = union(Key) { /// rendered at the next opportunity. render_inspector, + /// Export the Terminal IO inspector event log. + export_terminal_io: ExportTerminalIO, + /// Show a desktop notification. desktop_notification: DesktopNotification, @@ -204,6 +208,9 @@ pub const Action = union(Key) { /// Set the tab title override for the target's tab. set_tab_title: SetTitle, + /// Set the window title override for the target's tab. + set_window_title: SetTitle, + /// Set the title of the target to a prompted value. It is up to /// the apprt to prompt. The value specifies whether to prompt for the /// surface title or the tab title. @@ -227,7 +234,7 @@ pub const Action = union(Key) { /// Open the Ghostty configuration. This is platform-specific about /// what it means; it can mean opening a dedicated UI or just opening /// a file in a text editor. - open_config, + open_config: OpenConfig, /// Called when there are no more surfaces and the app should quit /// after the configured delay. @@ -347,6 +354,9 @@ pub const Action = union(Key) { /// otherwise the terminal-set title. copy_title_to_clipboard, + /// Move a tab to a new window. + move_tab_to_new_window, + /// Sync with: ghostty_action_tag_e pub const Key = enum(c_int) { quit, @@ -380,9 +390,11 @@ pub const Action = union(Key) { inspector, show_gtk_inspector, render_inspector, + export_terminal_io, desktop_notification, set_title, set_tab_title, + set_window_title, prompt_title, pwd, mouse_shape, @@ -415,6 +427,7 @@ pub const Action = union(Key) { search_selected, readonly, copy_title_to_clipboard, + move_tab_to_new_window, test "ghostty.h Action.Key" { try lib.checkGhosttyHEnum(Key, "GHOSTTY_ACTION_"); @@ -424,8 +437,11 @@ pub const Action = union(Key) { /// Sync with: ghostty_action_u pub const CValue = cvalue: { const key_fields = @typeInfo(Key).@"enum".fields; - var union_fields: [key_fields.len]std.builtin.Type.UnionField = undefined; - for (key_fields, 0..) |field, i| { + var names: [key_fields.len][]const u8 = undefined; + var types: [key_fields.len]type = undefined; + var attrs: [key_fields.len]std.builtin.Type.UnionField.Attributes = undefined; + + for (key_fields, &names, &types, &attrs) |field, *name, *ty, *attr| { const action = @unionInit(Action, field.name, undefined); const Type = t: { const Type = @TypeOf(@field(action, field.name)); @@ -434,19 +450,12 @@ pub const Action = union(Key) { break :t Type; }; - union_fields[i] = .{ - .name = field.name, - .type = Type, - .alignment = @alignOf(Type), - }; + name.* = field.name; + ty.* = Type; + attr.* = .{ .@"align" = @alignOf(Type) }; } - break :cvalue @Type(.{ .@"union" = .{ - .layout = .@"extern", - .tag_type = null, - .fields = &union_fields, - .decls = &.{}, - } }); + break :cvalue @Union(.@"extern", null, &names, &types, &attrs); }; /// Sync with: ghostty_action_s @@ -460,7 +469,7 @@ pub const Action = union(Key) { // At the time of writing, we don't promise ABI compatibility // so we can change this but I want to be aware of it. assert(@sizeOf(CValue) == switch (@sizeOf(usize)) { - 4 => 16, + 4 => 24, 8 => 24, else => unreachable, }); @@ -615,6 +624,37 @@ pub const Inspector = enum(c_int) { } }; +/// Terminal IO inspector contents to export. The contents are only valid for +/// the duration of the action callback. +pub const ExportTerminalIO = struct { + contents: []const u8, + + // Sync with: ghostty_action_export_terminal_io_s + pub const C = extern struct { + contents: [*]const u8, + len: usize, + }; + + pub fn cval(self: ExportTerminalIO) C { + return .{ + .contents = self.contents.ptr, + .len = self.contents.len, + }; + } + + pub fn format( + value: @This(), + comptime _: []const u8, + _: std.fmt.Options, + writer: *std.Io.Writer, + ) !void { + try writer.print( + "{s}{{ contents: {d} bytes }}", + .{ @typeName(@This()), value.contents.len }, + ); + } +}; + pub const QuitTimer = enum(c_int) { start, stop, @@ -642,10 +682,11 @@ pub const MouseVisibility = enum(c_int) { } }; -/// Whether to prompt for the surface title or tab title. +/// Whether to prompt for the surface, tab, or window title. pub const PromptTitle = enum(c_int) { surface, tab, + window, test "ghostty.h PromptTitle" { try lib.checkGhosttyHEnum(PromptTitle, "GHOSTTY_PROMPT_TITLE_"); @@ -713,7 +754,7 @@ pub const SetTitle = struct { pub fn format( value: @This(), comptime _: []const u8, - _: std.fmt.FormatOptions, + _: std.fmt.Options, writer: *std.Io.Writer, ) !void { try writer.print("{s}{{ {s} }}", .{ @typeName(@This()), value.title }); @@ -737,7 +778,7 @@ pub const Pwd = struct { pub fn format( value: @This(), comptime _: []const u8, - _: std.fmt.FormatOptions, + _: std.fmt.Options, writer: *std.Io.Writer, ) !void { try writer.print("{s}{{ {s} }}", .{ @typeName(@This()), value.pwd }); @@ -765,7 +806,7 @@ pub const DesktopNotification = struct { pub fn format( value: @This(), comptime _: []const u8, - _: std.fmt.FormatOptions, + _: std.fmt.Options, writer: *std.Io.Writer, ) !void { try writer.print("{s}{{ title: {s}, body: {s} }}", .{ @@ -909,6 +950,11 @@ pub const OpenUrl = struct { /// The URL is known to contain HTML content. html, + /// The URL came from an OSC 8 hyperlink. Application runtimes should + /// treat this as untrusted terminal output and apply a platform-specific + /// safe-opening policy. + osc8, + test "ghostty.h OpenUrl.Kind" { try lib.checkGhosttyHEnum(Kind, "GHOSTTY_ACTION_OPEN_URL_KIND_"); } @@ -1007,6 +1053,19 @@ pub const SearchSelected = struct { } }; +/// sync with ghostty_action_close_tab_mode_e in ghostty.h +pub const OpenConfig = enum(c_int) { + /// Open the config in the OS default editor. + os_open, + + /// Open the config in a new window using $EDITOR or $VISUAL + new_window, + + test "ghostty.h OpenConfig" { + try lib.checkGhosttyHEnum(OpenConfig, "GHOSTTY_ACTION_OPEN_CONFIG_"); + } +}; + test { - _ = std.testing.refAllDeclsRecursive(@This()); + _ = compat_testing.refAllDeclsRecursive(@This()); } diff --git a/vendor/libghostty-vt/src/apprt/embedded.zig b/vendor/libghostty-vt/src/apprt/embedded.zig index 7310159c..525bc856 100644 --- a/vendor/libghostty-vt/src/apprt/embedded.zig +++ b/vendor/libghostty-vt/src/apprt/embedded.zig @@ -11,6 +11,7 @@ const Allocator = std.mem.Allocator; const objc = @import("objc"); const apprt = @import("../apprt.zig"); const font = @import("../font/main.zig"); +const global = @import("../global.zig"); const input = @import("../input.zig"); const internal_os = @import("../os/main.zig"); const renderer = @import("../renderer.zig"); @@ -51,18 +52,36 @@ pub const App = struct { /// Callback called to handle an action. action: *const fn (*App, apprt.Target.C, apprt.Action.C) callconv(.c) bool, - /// Read the clipboard value. Returns true if the clipboard request - /// was started and complete_clipboard_request may be called with the - /// given state pointer. Returns false if the clipboard request couldn't - /// be started (such as when no text is available for a paste request). - read_clipboard: *const fn (SurfaceUD, c_int, *apprt.ClipboardRequest) callconv(.c) bool, + /// Read the clipboard value. The result only reports facts about + /// the clipboard: whether the request was started (in which case + /// complete_clipboard_request or deny_clipboard_request must + /// eventually be called with the given state pointer), whether the + /// clipboard has no servable contents, or whether the clipboard + /// can't be read at all. How each non-started state is answered is + /// up to the core. + /// + /// The MIME types are exactly the representations the caller wants + /// served; the embedder should read only those. Text-like types + /// are always requested as the canonical "text/plain". The final + /// bool asks for the listing of all MIME types available on the + /// clipboard to be delivered with the completion. + read_clipboard: *const fn ( + SurfaceUD, + c_int, + *apprt.ClipboardRequest, + [*]const [*:0]const u8, + usize, + bool, + ) callconv(.c) apprt.ClipboardReadResult, /// This may be called after a read clipboard call to request - /// confirmation that the clipboard value is safe to read. The embedder - /// must call complete_clipboard_request with the given request. + /// confirmation that the clipboard value is safe to read. The + /// embedder must call complete_clipboard_request (usually with + /// the confirmation's contents, which are only borrowed for + /// this call) or deny_clipboard_request with the given request. confirm_read_clipboard: *const fn ( SurfaceUD, - [*:0]const u8, + *const CAPI.ClipboardConfirm, *apprt.ClipboardRequest, apprt.ClipboardRequestType, ) callconv(.c) void, @@ -119,7 +138,13 @@ pub const App = struct { core_app: *CoreApp, opts: Options, - keymap: input.Keymap, + + /// The keyboard layout keymap. This is lazily initialized on first + /// use because creating it requires talking to the text input + /// system (TIS on macOS), and the first such call in a process is + /// slow (multiple milliseconds). It is only needed once keyboard + /// events start flowing, at which point the system is warm. + keymap: ?input.Keymap, /// The configuration for the app. This is owned by this structure. config: Config, @@ -135,19 +160,16 @@ pub const App = struct { var config_clone = try config.clone(alloc); errdefer config_clone.deinit(); - var keymap = try input.Keymap.init(); - errdefer keymap.deinit(); - self.* = .{ .core_app = core_app, .config = config_clone, .opts = opts, - .keymap = keymap, + .keymap = null, }; } pub fn terminate(self: *App) void { - self.keymap.deinit(); + if (self.keymap) |*v| v.deinit(); self.config.deinit(); } @@ -207,8 +229,10 @@ pub const App = struct { /// This should be called whenever the keyboard layout was changed. pub fn reloadKeymap(self: *App) !void { - // Reload the keymap - try self.keymap.reload(); + // Reload the keymap. If it was never initialized we don't need + // to do anything since lazy initialization will pick up the + // current layout. + if (self.keymap) |*v| try v.reload(); } /// Loads the keyboard layout. @@ -216,13 +240,25 @@ pub const App = struct { /// Kind of expensive so this should be avoided if possible. When I say /// "kind of expensive" I mean that its not something you probably want /// to run on every keypress. - pub fn keyboardLayout(self: *const App) input.KeyboardLayout { + pub fn keyboardLayout(self: *App) input.KeyboardLayout { // We only support keyboard layout detection on macOS. if (comptime builtin.os.tag != .macos) return .unknown; + // Lazily initialize the keymap. + const keymap: *input.Keymap = keymap: { + if (self.keymap == null) { + self.keymap = input.Keymap.init() catch |err| { + log.warn("error initializing keymap err={}", .{err}); + return .unknown; + }; + } + + break :keymap &self.keymap.?; + }; + // Any layout larger than this is not something we can handle. var buf: [256]u8 = undefined; - const id = self.keymap.sourceId(&buf) catch |err| { + const id = keymap.sourceId(&buf) catch |err| { comptime assert(@TypeOf(err) == error{OutOfMemory}); return .unknown; }; @@ -333,9 +369,10 @@ pub const App = struct { _: apprt.ipc.Target, comptime action: apprt.ipc.Action.Key, _: apprt.ipc.Action.Value(action), - ) (Allocator.Error || std.posix.WriteError || apprt.ipc.Errors)!bool { + ) (Allocator.Error || apprt.ipc.Errors)!bool { switch (action) { .new_window => return false, + .new_tab => return false, .toggle_quick_terminal => return false, } } @@ -372,7 +409,7 @@ pub const Platform = union(PlatformTag) { /// Initialize a Platform a tag and configuration from the C ABI. pub fn init(tag_int: c_int, c_platform: C) !Platform { - const tag = try std.meta.intToEnum(PlatformTag, tag_int); + const tag = std.enums.fromInt(PlatformTag, tag_int) orelse return error.InvalidEnumTag; return switch (tag) { .macos => if (MacOS != void) macos: { const config = c_platform.macos; @@ -490,16 +527,16 @@ pub const Surface = struct { 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| { + var dir = std.Io.Dir.openDirAbsolute(global.io(), wd, .{}) catch |err| { log.warn( "error opening requested working directory dir={s} err={}", .{ wd, err }, ); break :wd; }; - defer dir.close(); + defer dir.close(global.io()); - const stat = dir.stat() catch |err| { + const stat = dir.stat(global.io()) catch |err| { log.warn( "failed to stat requested working directory dir={s} err={}", .{ wd, err }, @@ -665,7 +702,13 @@ pub const Surface = struct { ) bool { return switch (clipboard_type) { .standard => true, - .selection, .primary => self.app.opts.supports_selection_clipboard, + .selection => self.app.opts.supports_selection_clipboard, + + // No embedder can serve the primary selection: the embedding + // API has no option to declare support for it, and macOS (the + // only GUI embedder) has no primary selection. The selection + // flag above covers a distinct custom pasteboard, not this. + .primary => false, }; } @@ -673,7 +716,67 @@ pub const Surface = struct { self: *Surface, clipboard_type: apprt.Clipboard, state: apprt.ClipboardRequest, - ) !bool { + ) !apprt.ClipboardReadResult { + // Kitty clipboard writes carry their own contents and read + // nothing from the clipboard, so they skip the read callback + // entirely: the request is completed immediately, and a + // completion that requires confirmation diverts into the + // apprt confirmation flow just like a read. + if (state == .kitty_write) { + // A write to a location the embedder can't serve is + // rejected before the transaction completes so the caller + // answers ENOSYS without a permission prompt, matching + // reads, where the embedder reports an unsupported + // location from the read callback. The write callback only + // fires after any prompt, so it is too late to reject. + if (!self.supportsClipboard(clipboard_type)) return .unsupported; + + const alloc = self.app.core_app.alloc; + const state_ptr = try alloc.create(apprt.ClipboardRequest); + errdefer alloc.destroy(state_ptr); + state_ptr.* = state; + self.completeKittyClipboardWrite(state_ptr); + return .started; + } + + // The representations the read wants served. Text-only + // requesters ask for the canonical text type; Kitty clipboard + // reads ask for exactly what the program requested, with + // text-like aliases normalized so the embedder never has to + // know about them. + var mimes_buf: [terminal.kitty.clipboard.max_read_mimes][*:0]const u8 = undefined; + const mimes: []const [*:0]const u8 = switch (state) { + .paste, .osc_52_read => &.{"text/plain"}, + + // A mode 5522 paste event only lists types and must not read + // any clipboard data. + .list => &.{}, + + .kitty_read => |kitty| mimes: { + assert(kitty.mimes.len <= mimes_buf.len); + for (kitty.mimes, mimes_buf[0..kitty.mimes.len]) |mime, *dst| { + dst.* = if (terminal.clipboard.isTextMime(mime)) + "text/plain" + else + mime.ptr; + } + break :mimes mimes_buf[0..kitty.mimes.len]; + }, + + // Handled above. + .kitty_write => unreachable, + + // No clipboard write code paths travel through this function + .osc_52_write => unreachable, + }; + const list = switch (state) { + // Paste events need the full MIME listing without reading any + // representation. Kitty reads only ask for it when requested. + .list => true, + .kitty_read => |kitty| kitty.list, + else => false, + }; + // We need to allocate to get a pointer to store our clipboard request // so that it is stable until the read_clipboard callback and call // complete_clipboard_request. This sucks but clipboard requests aren't @@ -683,40 +786,156 @@ pub const Surface = struct { errdefer alloc.destroy(state_ptr); state_ptr.* = state; - const started = self.app.opts.read_clipboard( + const result = self.app.opts.read_clipboard( self.userdata, @intCast(@intFromEnum(clipboard_type)), state_ptr, + mimes.ptr, + mimes.len, + list, ); - if (!started) { - alloc.destroy(state_ptr); - return false; - } - return true; + // Only a started request completes later and keeps the state. + if (result != .started) alloc.destroy(state_ptr); + return result; } - fn completeClipboardRequest( + /// Complete a Kitty clipboard protocol write request. The apprt + /// contributes nothing to the completion (the authoritative + /// contents live in the request itself), but a completion that + /// requires confirmation diverts into the confirm callback, which + /// keeps the state alive for the asynchronous prompt. + fn completeKittyClipboardWrite( self: *Surface, - str: [:0]const u8, state: *apprt.ClipboardRequest, - confirmed: bool, ) void { const alloc = self.app.core_app.alloc; + const kitty = state.kitty_write; - // Attempt to complete the request, but we may request - // confirmation. self.core_surface.completeClipboardRequest( state.*, - str, - confirmed, + .{}, ) catch |err| switch (err) { + error.UnauthorizedPaste => { + // Convert the would-be contents so the permission + // prompt can display exactly what would be written. + var stack = std.heap.stackFallback(1024, alloc); + const conv_alloc = stack.get(); + const contents = conv_alloc.alloc( + CAPI.ClipboardContent, + kitty.contents.len, + ) catch |alloc_err| { + log.err("error confirming clipboard request err={}", .{alloc_err}); + self.core_surface.denyClipboardRequest(state.*); + alloc.destroy(state); + return; + }; + defer conv_alloc.free(contents); + for (kitty.contents, contents) |src, *dst| dst.* = .{ + .mime = src.mime, + .data = src.data.ptr, + .len = src.data.len, + }; + + self.app.opts.confirm_read_clipboard( + self.userdata, + &.{ + .contents = contents.ptr, + .contents_len = contents.len, + .available = null, + .available_len = 0, + .name = if (kitty.name.len > 0) kitty.name.ptr else null, + .can_remember = kitty.pw.len > 0, + }, + state, + state.*, + ); + + return; + }, + + else => log.err("error completing clipboard request err={}", .{err}), + }; + + // We don't defer this because the clipboard confirmation route + // preserves the clipboard request. + alloc.destroy(state); + } + + fn completeClipboardRequest( + self: *Surface, + complete: *const CAPI.ClipboardComplete, + state: *apprt.ClipboardRequest, + ) void { + const alloc = self.app.core_app.alloc; + + // Convert the C representations to the core types. Everything + // remains borrowed from the caller for the duration of the call. + var stack = std.heap.stackFallback(1024, alloc); + const conv_alloc = stack.get(); + + const raw_contents: []const CAPI.ClipboardContent = + if (complete.contents) |v| v[0..complete.contents_len] else &.{}; + const contents = conv_alloc.alloc( + terminal.clipboard.Content, + raw_contents.len, + ) catch |err| { + log.err("error completing clipboard request err={}", .{err}); + alloc.destroy(state); + return; + }; + defer conv_alloc.free(contents); + for (raw_contents, contents) |raw, *content| content.* = .{ + .mime = std.mem.sliceTo(raw.mime, 0), + .data = raw.data[0..raw.len], + }; + + const raw_available: []const [*:0]const u8 = + if (complete.available) |v| v[0..complete.available_len] else &.{}; + const available = conv_alloc.alloc( + []const u8, + raw_available.len, + ) catch |err| { + log.err("error completing clipboard request err={}", .{err}); + alloc.destroy(state); + return; + }; + defer conv_alloc.free(available); + for (raw_available, available) |raw, *mime| { + mime.* = std.mem.sliceTo(raw, 0); + } + + // Attempt to complete the request, but we may request + // confirmation. + self.core_surface.completeClipboardRequest(state.*, .{ + .contents = contents, + .available = available, + .confirmed = complete.confirmed, + .remember = complete.remember, + }) catch |err| switch (err) { error.UnsafePaste, error.UnauthorizedPaste, => { + // Session grant information for the permission prompt, + // carried only by Kitty clipboard protocol requests. + const name: ?[*:0]const u8, const can_remember: bool = switch (state.*) { + inline .kitty_read, .kitty_write => |kitty| .{ + if (kitty.name.len > 0) kitty.name.ptr else null, + kitty.pw.len > 0, + }, + else => .{ null, false }, + }; + self.app.opts.confirm_read_clipboard( self.userdata, - str.ptr, + &.{ + .contents = complete.contents, + .contents_len = complete.contents_len, + .available = complete.available, + .available_len = complete.available_len, + .name = name, + .can_remember = can_remember, + }, state, state.*, ); @@ -732,6 +951,14 @@ pub const Surface = struct { alloc.destroy(state); } + fn denyClipboardRequest( + self: *Surface, + state: *apprt.ClipboardRequest, + ) void { + self.core_surface.denyClipboardRequest(state.*); + self.app.core_app.alloc.destroy(state); + } + pub fn setClipboard( self: *const Surface, clipboard_type: apprt.Clipboard, @@ -744,7 +971,8 @@ pub const Surface = struct { for (contents, 0..) |content, i| { array[i] = .{ .mime = content.mime, - .data = content.data, + .data = content.data.ptr, + .len = content.data.len, }; } @@ -950,32 +1178,32 @@ pub const Surface = struct { }; } - pub fn defaultTermioEnv(self: *const Surface) !std.process.EnvMap { - const alloc = self.app.core_app.alloc; - var env = try internal_os.getEnvMap(alloc); + pub fn defaultTermioEnv(self: *const Surface) !std.process.Environ.Map { + _ = self; + var env = try global.environMap(); errdefer env.deinit(); if (comptime builtin.target.os.tag.isDarwin()) { if (env.get("__XCODE_BUILT_PRODUCTS_DIR_PATHS") != null) { - env.remove("__XCODE_BUILT_PRODUCTS_DIR_PATHS"); - env.remove("__XPC_DYLD_LIBRARY_PATH"); - env.remove("DYLD_FRAMEWORK_PATH"); - env.remove("DYLD_INSERT_LIBRARIES"); - env.remove("DYLD_LIBRARY_PATH"); - env.remove("LD_LIBRARY_PATH"); - env.remove("SECURITYSESSIONID"); - env.remove("XPC_SERVICE_NAME"); + _ = env.orderedRemove("__XCODE_BUILT_PRODUCTS_DIR_PATHS"); + _ = env.orderedRemove("__XPC_DYLD_LIBRARY_PATH"); + _ = env.orderedRemove("DYLD_FRAMEWORK_PATH"); + _ = env.orderedRemove("DYLD_INSERT_LIBRARIES"); + _ = env.orderedRemove("DYLD_LIBRARY_PATH"); + _ = env.orderedRemove("LD_LIBRARY_PATH"); + _ = env.orderedRemove("SECURITYSESSIONID"); + _ = env.orderedRemove("XPC_SERVICE_NAME"); } // Remove this so that running `ghostty` within Ghostty works. - env.remove("GHOSTTY_MAC_LAUNCH_SOURCE"); + _ = env.orderedRemove("GHOSTTY_MAC_LAUNCH_SOURCE"); // If we were launched from the desktop then we want to // remove the LANGUAGE env var so that we don't inherit // 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"); + if (internal_os.launchedFromDesktop()) _ = env.orderedRemove("LANGUAGE"); } return env; @@ -1000,7 +1228,7 @@ pub const Inspector = struct { content_scale: f64 = 1, /// Our previous instant used to calculate delta time for animations. - instant: ?std.time.Instant = null, + instant: ?std.Io.Timestamp = null, const Backend = enum { metal, @@ -1229,9 +1457,9 @@ pub const Inspector = struct { const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO(); // Determine our delta time - const now = try std.time.Instant.now(); + const now: std.Io.Timestamp = .now(global.io(), .awake); io.DeltaTime = if (self.instant) |prev| delta: { - const since_ns: f64 = @floatFromInt(now.since(prev)); + const since_ns: f64 = @floatFromInt(prev.durationTo(now).toNanoseconds()); const ns_per_s: f64 = @floatFromInt(std.time.ns_per_s); const since_s: f32 = @floatCast(since_ns / ns_per_s); break :delta @max(0.00001, since_s); @@ -1242,8 +1470,6 @@ pub const Inspector = struct { // C API pub const CAPI = struct { - const global = &@import("../global.zig").state; - /// This is the same as Surface.KeyEvent but this is the raw C API version. const KeyEvent = extern struct { action: input.Action, @@ -1284,9 +1510,48 @@ pub const CAPI = struct { }; // ghostty_clipboard_content_s + // + // One representation of clipboard contents. The data is binary-safe + // and its length is explicit; it is not sentinel-terminated. const ClipboardContent = extern struct { mime: [*:0]const u8, - data: [*:0]const u8, + data: [*]const u8, + len: usize, + }; + + // ghostty_clipboard_complete_s + // + // The payload for completing a clipboard read request. See + // Surface.CompleteClipboard for the field documentation. + const ClipboardComplete = extern struct { + contents: ?[*]const ClipboardContent, + contents_len: usize, + available: ?[*]const [*:0]const u8, + available_len: usize, + confirmed: bool, + remember: bool, + }; + + // ghostty_clipboard_confirm_s + // + // The payload of a clipboard read confirmation request: the + // would-be completion contents plus the information shown in the + // permission prompt. All memory is borrowed for the duration of + // the confirm_read_clipboard callback. + const ClipboardConfirm = extern struct { + contents: ?[*]const ClipboardContent, + contents_len: usize, + available: ?[*]const [*:0]const u8, + available_len: usize, + + /// The human friendly name of the requesting program for the + /// prompt, null when the protocol doesn't carry one. + name: ?[*:0]const u8, + + /// True when the user's decision may be remembered as a + /// session grant, reported back through the completion's + /// remember field. + can_remember: bool, }; // ghostty_text_s @@ -1300,7 +1565,7 @@ pub const CAPI = struct { pub fn deinit(self: *Text) void { if (self.text) |ptr| { - global.alloc.free(ptr[0..self.text_len :0]); + global.alloc().free(ptr[0..self.text_len :0]); } } }; @@ -1410,12 +1675,12 @@ pub const CAPI = struct { opts: *const apprt.runtime.App.Options, config: *const Config, ) !*App { - const 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); + var app = try global.alloc().create(App); + errdefer global.alloc().destroy(app); try app.init(core_app, config, opts.*); errdefer app.terminate(); @@ -1438,7 +1703,7 @@ pub const CAPI = struct { export fn ghostty_app_free(v: *App) void { const core_app = v.core_app; v.terminate(); - global.alloc.destroy(v); + global.alloc().destroy(v); core_app.destroy(); } @@ -1490,7 +1755,7 @@ pub const CAPI = struct { /// Open the configuration. export fn ghostty_app_open_config(v: *App) void { - _ = v.performAction(.app, .open_config, {}) catch |err| { + _ = v.performAction(.app, .open_config, .new_window) catch |err| { log.err("error reloading config err={}", .{err}); return; }; @@ -1520,13 +1785,7 @@ pub const CAPI = struct { /// Update the color scheme of the app. export fn ghostty_app_set_color_scheme(v: *App, scheme_raw: c_int) void { - const scheme = std.meta.intToEnum(apprt.ColorScheme, scheme_raw) catch { - log.warn( - "invalid color scheme to ghostty_surface_set_color_scheme value={}", - .{scheme_raw}, - ); - return; - }; + const scheme = std.enums.fromInt(apprt.ColorScheme, scheme_raw) orelse return; v.core_app.colorSchemeEvent(v, scheme) catch |err| { log.err("error setting color scheme err={}", .{err}); @@ -1612,8 +1871,8 @@ pub const CAPI = struct { result: *Text, ) bool { const core_surface = &surface.core_surface; - core_surface.renderer_state.mutex.lock(); - defer core_surface.renderer_state.mutex.unlock(); + core_surface.renderer_state.mutex.lockUncancelable(global.io()); + defer core_surface.renderer_state.mutex.unlock(global.io()); // If we don't have a selection, do nothing. const core_sel = core_surface.io.terminal.screens.active.selection orelse return false; @@ -1632,8 +1891,8 @@ pub const CAPI = struct { sel: Selection, result: *Text, ) bool { - surface.core_surface.renderer_state.mutex.lock(); - defer surface.core_surface.renderer_state.mutex.unlock(); + surface.core_surface.renderer_state.mutex.lockUncancelable(global.io()); + defer surface.core_surface.renderer_state.mutex.unlock(global.io()); const core_sel = sel.core( surface.core_surface.renderer_state.terminal.screens.active, @@ -1651,7 +1910,7 @@ pub const CAPI = struct { // Get our text directly from the core surface. const text = core_surface.dumpTextLocked( - global.alloc, + global.alloc(), core_sel, ) catch |err| { log.warn("error reading text err={}", .{err}); @@ -1730,14 +1989,7 @@ pub const CAPI = struct { /// Update the color scheme of the surface. export fn ghostty_surface_set_color_scheme(surface: *Surface, scheme_raw: c_int) void { - const scheme = std.meta.intToEnum(apprt.ColorScheme, scheme_raw) catch { - log.warn( - "invalid color scheme to ghostty_surface_set_color_scheme value={}", - .{scheme_raw}, - ); - return; - }; - + const scheme = std.enums.fromInt(apprt.ColorScheme, scheme_raw) orelse return; surface.colorSchemeCallback(scheme); } @@ -1891,17 +2143,7 @@ pub const CAPI = struct { stage_raw: u32, pressure: f64, ) void { - const stage = std.meta.intToEnum( - input.MousePressureStage, - stage_raw, - ) catch { - log.warn( - "invalid mouse pressure stage value={}", - .{stage_raw}, - ); - return; - }; - + const stage = std.enums.fromInt(input.MousePressureStage, stage_raw) orelse return; surface.mousePressureCallback(stage, pressure); } @@ -2001,20 +2243,32 @@ pub const CAPI = struct { }; } - /// Complete a clipboard read request started via the read callback. - /// This can only be called once for a given request. Once it is called - /// with a request the request pointer will be invalidated. + /// Complete a clipboard read request started via the read callback + /// with the representations that could be served and, if requested, + /// the listing of available MIME types. All memory is borrowed for + /// the duration of the call. This can only be called once for a given + /// request. Once it is called with a request the request pointer will + /// be invalidated. + /// + /// To deny a request use ghostty_surface_deny_clipboard_request + /// instead. export fn ghostty_surface_complete_clipboard_request( ptr: *Surface, - str: [*:0]const u8, + complete: *const ClipboardComplete, state: *apprt.ClipboardRequest, - confirmed: bool, ) void { - ptr.completeClipboardRequest( - std.mem.sliceTo(str, 0), - state, - confirmed, - ); + ptr.completeClipboardRequest(complete, state); + } + + /// Deny a clipboard read request started via the read callback, + /// e.g. because the user rejected a confirmation prompt. Request + /// types whose protocol expects an answer have their denial reply + /// written to the pty. The request pointer is invalidated. + export fn ghostty_surface_deny_clipboard_request( + ptr: *Surface, + state: *apprt.ClipboardRequest, + ) void { + ptr.denyClipboardRequest(state); } export fn ghostty_surface_inspector(ptr: *Surface) ?*Inspector { @@ -2134,6 +2388,7 @@ pub const CAPI = struct { export fn ghostty_surface_set_display_id(ptr: *Surface, display_id: u32) void { const surface = &ptr.core_surface; _ = surface.renderer_thread.mailbox.push( + global.io(), .{ .macos_display_id = display_id }, .{ .forever = {} }, ); @@ -2157,8 +2412,8 @@ pub const CAPI = struct { // read the font face. It should not be deferred since // we're loading the primary face. const grid = ptr.core_surface.renderer.font_grid; - grid.lock.lockShared(); - defer grid.lock.unlockShared(); + grid.lock.lockSharedUncancelable(global.io()); + defer grid.lock.unlockShared(global.io()); const collection = &grid.resolver.collection; const face = collection.getFace(.{}) catch return null; @@ -2195,8 +2450,8 @@ pub const CAPI = struct { result: *Text, ) bool { const surface = &ptr.core_surface; - surface.renderer_state.mutex.lock(); - defer surface.renderer_state.mutex.unlock(); + surface.renderer_state.mutex.lockUncancelable(global.io()); + defer surface.renderer_state.mutex.unlock(global.io()); // Get our word selection const sel = sel: { diff --git a/vendor/libghostty-vt/src/apprt/gtk/App.zig b/vendor/libghostty-vt/src/apprt/gtk/App.zig index 8a7b3a8e..9fbea7c1 100644 --- a/vendor/libghostty-vt/src/apprt/gtk/App.zig +++ b/vendor/libghostty-vt/src/apprt/gtk/App.zig @@ -13,6 +13,7 @@ const CoreApp = @import("../../App.zig"); const Application = @import("class/application.zig").Application; const Surface = @import("Surface.zig"); const ipcNewWindow = @import("ipc/new_window.zig").newWindow; +const ipcNewTab = @import("ipc/new_tab.zig").newTab; const ipcToggleQuickTerminal = @import("ipc/toggle_quick_terminal.zig").toggleQuickTerminal; const log = std.log.scoped(.gtk); @@ -85,6 +86,7 @@ pub fn performIpc( ) !bool { switch (action) { .new_window => return try ipcNewWindow(alloc, target, value), + .new_tab => return try ipcNewTab(alloc, target, value), .toggle_quick_terminal => return try ipcToggleQuickTerminal(alloc, target), } } diff --git a/vendor/libghostty-vt/src/apprt/gtk/Surface.zig b/vendor/libghostty-vt/src/apprt/gtk/Surface.zig index 71597367..4a59ab21 100644 --- a/vendor/libghostty-vt/src/apprt/gtk/Surface.zig +++ b/vendor/libghostty-vt/src/apprt/gtk/Surface.zig @@ -74,7 +74,7 @@ pub fn clipboardRequest( self: *Self, clipboard_type: apprt.Clipboard, state: apprt.ClipboardRequest, -) !bool { +) !apprt.ClipboardReadResult { return try self.surface.clipboardRequest( clipboard_type, state, @@ -94,7 +94,7 @@ pub fn setClipboard( ); } -pub fn defaultTermioEnv(self: *Self) !std.process.EnvMap { +pub fn defaultTermioEnv(self: *Self) !std.process.Environ.Map { return try self.surface.defaultTermioEnv(); } diff --git a/vendor/libghostty-vt/src/apprt/gtk/adw_version.zig b/vendor/libghostty-vt/src/apprt/gtk/adw_version.zig index 6f7be52d..1ac750fb 100644 --- a/vendor/libghostty-vt/src/apprt/gtk/adw_version.zig +++ b/vendor/libghostty-vt/src/apprt/gtk/adw_version.zig @@ -4,18 +4,16 @@ const std = @import("std"); // Ghostty, we need to import `adwaita.h` directly to ensure that the version // macros match the version of `libadwaita` that we are building/linking // against. -const c = @cImport({ - @cInclude("adwaita.h"); -}); +const adw_c = @import("adw_c"); const adw = @import("adw"); const log = std.log.scoped(.gtk); pub const comptime_version: std.SemanticVersion = .{ - .major = c.ADW_MAJOR_VERSION, - .minor = c.ADW_MINOR_VERSION, - .patch = c.ADW_MICRO_VERSION, + .major = adw_c.ADW_MAJOR_VERSION, + .minor = adw_c.ADW_MINOR_VERSION, + .patch = adw_c.ADW_MICRO_VERSION, }; pub fn getRuntimeVersion() std.SemanticVersion { @@ -89,14 +87,14 @@ test "versionAtLeast" { const funs = &.{ atLeast, runtimeAtLeast }; inline for (funs) |fun| { - try testing.expect(fun(c.ADW_MAJOR_VERSION, c.ADW_MINOR_VERSION, c.ADW_MICRO_VERSION)); - try testing.expect(!fun(c.ADW_MAJOR_VERSION, c.ADW_MINOR_VERSION, c.ADW_MICRO_VERSION + 1)); - try testing.expect(!fun(c.ADW_MAJOR_VERSION, c.ADW_MINOR_VERSION + 1, c.ADW_MICRO_VERSION)); - try testing.expect(!fun(c.ADW_MAJOR_VERSION + 1, c.ADW_MINOR_VERSION, c.ADW_MICRO_VERSION)); - try testing.expect(fun(c.ADW_MAJOR_VERSION - 1, c.ADW_MINOR_VERSION, c.ADW_MICRO_VERSION)); - try testing.expect(fun(c.ADW_MAJOR_VERSION - 1, c.ADW_MINOR_VERSION + 1, c.ADW_MICRO_VERSION)); - try testing.expect(fun(c.ADW_MAJOR_VERSION - 1, c.ADW_MINOR_VERSION, c.ADW_MICRO_VERSION + 1)); - try testing.expect(fun(c.ADW_MAJOR_VERSION, c.ADW_MINOR_VERSION - 1, c.ADW_MICRO_VERSION + 1)); + try testing.expect(fun(adw_c.ADW_MAJOR_VERSION, adw_c.ADW_MINOR_VERSION, adw_c.ADW_MICRO_VERSION)); + try testing.expect(!fun(adw_c.ADW_MAJOR_VERSION, adw_c.ADW_MINOR_VERSION, adw_c.ADW_MICRO_VERSION + 1)); + try testing.expect(!fun(adw_c.ADW_MAJOR_VERSION, adw_c.ADW_MINOR_VERSION + 1, adw_c.ADW_MICRO_VERSION)); + try testing.expect(!fun(adw_c.ADW_MAJOR_VERSION + 1, adw_c.ADW_MINOR_VERSION, adw_c.ADW_MICRO_VERSION)); + try testing.expect(fun(adw_c.ADW_MAJOR_VERSION - 1, adw_c.ADW_MINOR_VERSION, adw_c.ADW_MICRO_VERSION)); + try testing.expect(fun(adw_c.ADW_MAJOR_VERSION - 1, adw_c.ADW_MINOR_VERSION + 1, adw_c.ADW_MICRO_VERSION)); + try testing.expect(fun(adw_c.ADW_MAJOR_VERSION - 1, adw_c.ADW_MINOR_VERSION, adw_c.ADW_MICRO_VERSION + 1)); + try testing.expect(fun(adw_c.ADW_MAJOR_VERSION, adw_c.ADW_MINOR_VERSION - 1, adw_c.ADW_MICRO_VERSION + 1)); } } diff --git a/vendor/libghostty-vt/src/apprt/gtk/build/blueprint.zig b/vendor/libghostty-vt/src/apprt/gtk/build/blueprint.zig index 4920ce6f..fb3f6ae2 100644 --- a/vendor/libghostty-vt/src/apprt/gtk/build/blueprint.zig +++ b/vendor/libghostty-vt/src/apprt/gtk/build/blueprint.zig @@ -6,10 +6,7 @@ //! Example: blueprint.zig 1 5 output.ui input.blp const std = @import("std"); - -pub const c = @cImport({ - @cInclude("adwaita.h"); -}); +const adw_c = @import("adw_c"); pub const blueprint_compiler_help = \\ @@ -26,23 +23,24 @@ pub const blueprint_compiler_help = ; const adwaita_version = std.SemanticVersion{ - .major = c.ADW_MAJOR_VERSION, - .minor = c.ADW_MINOR_VERSION, - .patch = c.ADW_MICRO_VERSION, + .major = adw_c.ADW_MAJOR_VERSION, + .minor = adw_c.ADW_MINOR_VERSION, + .patch = adw_c.ADW_MICRO_VERSION, }; + const required_blueprint_version = std.SemanticVersion{ .major = 0, .minor = 16, .patch = 0, }; -pub fn main() !void { +pub fn main(init: std.process.Init) !void { var debug_allocator: std.heap.DebugAllocator(.{}) = .init; defer _ = debug_allocator.deinit(); const alloc = debug_allocator.allocator(); // Get our args - var it = try std.process.argsWithAllocator(alloc); + var it = try init.minimal.args.iterateAllocator(alloc); defer it.deinit(); _ = it.next(); // Skip argv0 const arg_major = it.next() orelse return error.NoMajorVersion; @@ -63,51 +61,37 @@ pub fn main() !void { \\compile this blueprint. Please install it, ensure that it is \\available on your PATH, and then retry building Ghostty. , .{required_adwaita_version}); - std.posix.exit(1); + std.process.exit(1); } // Version checks { - var stdout: std.ArrayListUnmanaged(u8) = .empty; - defer stdout.deinit(alloc); - var stderr: std.ArrayListUnmanaged(u8) = .empty; - defer stderr.deinit(alloc); - - var blueprint_compiler = std.process.Child.init( - &.{ - "blueprint-compiler", - "--version", - }, - alloc, - ); - blueprint_compiler.stdout_behavior = .Pipe; - blueprint_compiler.stderr_behavior = .Pipe; - try blueprint_compiler.spawn(); - try blueprint_compiler.collectOutput( - alloc, - &stdout, - &stderr, - std.math.maxInt(u16), - ); - const term = blueprint_compiler.wait() catch |err| switch (err) { + const blueprint_compiler = std.process.run(alloc, init.io, .{ + .argv = &.{ "blueprint-compiler", "--version" }, + }) catch |err| switch (err) { error.FileNotFound => { std.debug.print( \\`blueprint-compiler` not found. ++ blueprint_compiler_help, .{required_blueprint_version}, ); - std.posix.exit(1); + std.process.exit(1); }, else => return err, }; - switch (term) { - .Exited => |rc| if (rc != 0) std.process.exit(1), + defer { + alloc.free(blueprint_compiler.stdout); + alloc.free(blueprint_compiler.stderr); + } + + switch (blueprint_compiler.term) { + .exited => |rc| if (rc != 0) std.process.exit(1), else => std.process.exit(1), } const version = try std.SemanticVersion.parse(std.mem.trim( u8, - stdout.items, + blueprint_compiler.stdout, &std.ascii.whitespace, )); if (version.order(required_blueprint_version) == .lt) { @@ -116,57 +100,45 @@ pub fn main() !void { ++ blueprint_compiler_help, .{required_blueprint_version}, ); - std.posix.exit(1); + std.process.exit(1); } } // Compilation { - var stdout: std.ArrayListUnmanaged(u8) = .empty; - defer stdout.deinit(alloc); - var stderr: std.ArrayListUnmanaged(u8) = .empty; - defer stderr.deinit(alloc); - - var blueprint_compiler = std.process.Child.init( - &.{ + const blueprint_compiler = std.process.run(alloc, init.io, .{ + .argv = &.{ "blueprint-compiler", "compile", "--output", output, input, }, - alloc, - ); - blueprint_compiler.stdout_behavior = .Pipe; - blueprint_compiler.stderr_behavior = .Pipe; - try blueprint_compiler.spawn(); - try blueprint_compiler.collectOutput( - alloc, - &stdout, - &stderr, - std.math.maxInt(u16), - ); - const term = blueprint_compiler.wait() catch |err| switch (err) { + }) catch |err| switch (err) { error.FileNotFound => { std.debug.print( \\`blueprint-compiler` not found. ++ blueprint_compiler_help, .{required_blueprint_version}, ); - std.posix.exit(1); + std.process.exit(1); }, else => return err, }; + defer { + alloc.free(blueprint_compiler.stdout); + alloc.free(blueprint_compiler.stderr); + } - switch (term) { - .Exited => |rc| { + switch (blueprint_compiler.term) { + .exited => |rc| { if (rc != 0) { - std.debug.print("{s}", .{stderr.items}); + std.debug.print("{s}", .{blueprint_compiler.stderr}); std.process.exit(1); } }, else => { - std.debug.print("{s}", .{stderr.items}); + std.debug.print("{s}", .{blueprint_compiler.stderr}); std.process.exit(1); }, } diff --git a/vendor/libghostty-vt/src/apprt/gtk/build/gresource.zig b/vendor/libghostty-vt/src/apprt/gtk/build/gresource.zig index c50ea8cd..57c32a35 100644 --- a/vendor/libghostty-vt/src/apprt/gtk/build/gresource.zig +++ b/vendor/libghostty-vt/src/apprt/gtk/build/gresource.zig @@ -122,18 +122,17 @@ pub fn blueprint(comptime bp: Blueprint) [:0]const u8 { } } -pub fn main() !void { - var debug_allocator: std.heap.DebugAllocator(.{}) = .init; - defer _ = debug_allocator.deinit(); - const alloc = debug_allocator.allocator(); +pub fn main(init: std.process.Init) !void { + const alloc = init.arena.allocator(); // Collect the UI files that are passed in as arguments. - var ui_files: std.ArrayListUnmanaged([]const u8) = .empty; + var ui_files: std.ArrayList([]const u8) = .empty; defer { for (ui_files.items) |item| alloc.free(item); ui_files.deinit(alloc); } - var it = try std.process.argsWithAllocator(alloc); + + var it = try init.minimal.args.iterateAllocator(alloc); defer it.deinit(); while (it.next()) |arg| { if (!std.mem.endsWith(u8, arg, ".ui")) continue; @@ -144,7 +143,7 @@ pub fn main() !void { } var buf: [4096]u8 = undefined; - var stdout = std.fs.File.stdout().writer(&buf); + var stdout = std.Io.File.stdout().writer(init.io, &buf); const writer = &stdout.interface; try writer.writeAll( \\ @@ -152,8 +151,8 @@ pub fn main() !void { \\ ); - try genRoot(writer); - try genIcons(writer); + try genRoot(init.io, writer); + try genIcons(init.io, writer); try genUi(alloc, writer, &ui_files); try writer.writeAll( @@ -167,19 +166,19 @@ pub fn main() !void { /// Generate the icon resources. This works by looking up all the icons /// specified by `icon_sizes` in `images/icons/`. They are asserted to exist /// by trying to access the file. -fn genIcons(writer: *std.Io.Writer) !void { +fn genIcons(io: std.Io, writer: *std.Io.Writer) !void { try writer.print( \\ \\ , .{build_info.resource_path}); - const cwd = std.fs.cwd(); + const cwd: std.Io.Dir = .cwd(); inline for (icon_sizes) |size| { // 1x { const alias = std.fmt.comptimePrint("{d}x{d}", .{ size, size }); const source = std.fmt.comptimePrint("images/gnome/{d}.png", .{size}); - try cwd.access(source, .{}); + try cwd.access(io, source, .{}); try writer.print( \\ {s} \\ @@ -192,7 +191,7 @@ fn genIcons(writer: *std.Io.Writer) !void { { const alias = std.fmt.comptimePrint("{d}x{d}@2", .{ size, size }); const source = std.fmt.comptimePrint("images/gnome/{d}.png", .{size * 2}); - try cwd.access(source, .{}); + try cwd.access(io, source, .{}); try writer.print( \\ {s} \\ @@ -209,19 +208,19 @@ fn genIcons(writer: *std.Io.Writer) !void { } /// Generate the resources at the root prefix. -fn genRoot(writer: *std.Io.Writer) !void { +fn genRoot(io: std.Io, writer: *std.Io.Writer) !void { try writer.print( \\ \\ , .{build_info.resource_path}); - const cwd = std.fs.cwd(); + const cwd: std.Io.Dir = .cwd(); inline for (css) |name| { const source = std.fmt.comptimePrint( "{s}/{s}", .{ css_path, name }, ); - try cwd.access(source, .{}); + try cwd.access(io, source, .{}); try writer.print( \\ {s} \\ @@ -242,7 +241,7 @@ fn genRoot(writer: *std.Io.Writer) !void { fn genUi( alloc: Allocator, writer: *std.Io.Writer, - files: *const std.ArrayListUnmanaged([]const u8), + files: *const std.ArrayList([]const u8), ) !void { try writer.print( \\ diff --git a/vendor/libghostty-vt/src/apprt/gtk/class.zig b/vendor/libghostty-vt/src/apprt/gtk/class.zig index 942666cf..a1dba716 100644 --- a/vendor/libghostty-vt/src/apprt/gtk/class.zig +++ b/vendor/libghostty-vt/src/apprt/gtk/class.zig @@ -145,16 +145,24 @@ pub fn Common( /// as the virtual method but the self parameter points to the /// target instead of the original class. fn ImplementFunc(comptime T: type) type { - var params: [fn_info.params.len]std.builtin.Type.Fn.Param = undefined; - @memcpy(¶ms, fn_info.params); - params[0].type = *ClassInstance(T); - return @Type(.{ .@"fn" = .{ - .calling_convention = fn_info.calling_convention, - .is_generic = fn_info.is_generic, - .is_var_args = fn_info.is_var_args, - .return_type = fn_info.return_type, - .params = ¶ms, - } }); + var types: [fn_info.params.len]type = undefined; + var attrs: [fn_info.params.len]std.builtin.Type.Fn.Param.Attributes = undefined; + + for (fn_info.params, &types, &attrs) |info, *ty, *attr| { + ty.* = info.type.?; + attr.* = .{ .@"noalias" = info.is_noalias }; + } + types[0] = *ClassInstance(T); + + return @Fn( + &types, + &attrs, + fn_info.return_type.?, + .{ + .@"callconv" = fn_info.calling_convention, + .varargs = fn_info.is_var_args, + }, + ); } }; } diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/application.zig b/vendor/libghostty-vt/src/apprt/gtk/class/application.zig index a627086e..5b9d3bf2 100644 --- a/vendor/libghostty-vt/src/apprt/gtk/class/application.zig +++ b/vendor/libghostty-vt/src/apprt/gtk/class/application.zig @@ -1,3 +1,4 @@ +const builtin = @import("builtin"); const std = @import("std"); const assert = @import("../../../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; @@ -5,25 +6,27 @@ const adw = @import("adw"); const gdk = @import("gdk"); const gio = @import("gio"); const glib = @import("glib"); +const glibunix = @import("glibunix"); const gobject = @import("gobject"); const gtk = @import("gtk"); const build_config = @import("../../../build_config.zig"); const build_info = @import("../build/info.zig"); -const state = &@import("../../../global.zig").state; +const cli = @import("../../../cli.zig"); +const global = @import("../../../global.zig"); const i18n = @import("../../../os/main.zig").i18n; const apprt = @import("../../../apprt.zig"); const CoreApp = @import("../../../App.zig"); +const compat_file = @import("../../../lib/compat/file.zig"); const configpkg = @import("../../../config.zig"); const input = @import("../../../input.zig"); const internal_os = @import("../../../os/main.zig"); const systemd = @import("../../../os/systemd.zig"); const terminal = @import("../../../terminal/main.zig"); -const xev = @import("../../../global.zig").xev; +const xev = global.xev; const Binding = @import("../../../input.zig").Binding; const CoreConfig = configpkg.Config; const CoreSurface = @import("../../../Surface.zig"); -const lib = @import("../../../lib/main.zig"); const ext = @import("../ext.zig"); const key = @import("../key.zig"); @@ -42,9 +45,12 @@ const CloseConfirmationDialog = @import("close_confirmation_dialog.zig").CloseCo const ConfigErrorsDialog = @import("config_errors_dialog.zig").ConfigErrorsDialog; const GlobalShortcuts = @import("global_shortcuts.zig").GlobalShortcuts; const OpenURI = @import("../portal.zig").OpenURI; +const media = @import("../media.zig"); const log = std.log.scoped(.gtk_ghostty_application); +extern "c" fn setenv(name: ?[*]const u8, value: ?[*]const u8, overwrite: c_int) c_int; + /// Function used to funnel GLib/GObject/GTK log messages into Zig's logging /// system rather than just getting dumped directly to stderr. fn glibLogWriterFunction( @@ -218,6 +224,12 @@ pub const Application = extern struct { open_uri: OpenURI = undefined, + // The audio bell's MediaFile, reused across bells so we don't leak a + // GStreamer pipeline (and its GL threads) on every ring. Built lazily + // on the first audio bell and rebuilt when `bell-audio-path` changes; + // unref'd on dispose. See ringBell and media.zig. + bell_media: ?*gtk.MediaFile = null, + pub var offset: c_int = 0; }; @@ -271,15 +283,17 @@ pub const Application = extern struct { }; defer config.deinit(); + // Set the old language, const saved_language: ?[:0]const u8 = saved_language: { const old_language = old_language: { - const result = (internal_os.getenv(alloc, "LANG") catch break :old_language null) orelse break :old_language null; - defer result.deinit(alloc); - break :old_language alloc.dupeZ(u8, result.value) catch break :old_language null; + const lang = global.environ().getPosix("LANG") orelse break :old_language null; + break :old_language alloc.dupeSentinel(u8, @ptrCast(lang), 0) catch null; }; - - if (config.language) |language| _ = internal_os.setenv("LANG", language); - + if (config.language) |language| { + // Override LANG if we need to (sync global environs if so) + _ = setenv("LANG", @ptrCast(language), 1); + global.syncEnviron(); + } break :saved_language old_language; }; @@ -294,7 +308,7 @@ pub const Application = extern struct { // Setup our GTK init env vars setGtkEnv(&config) catch |err| switch (err) { - error.NoSpaceLeft => { + error.WriteFailed => { // If we fail to set GTK environment variables then we still // try to start the application... log.warn( @@ -338,7 +352,7 @@ pub const Application = extern struct { // I'm unsure of any scenario where this happens. Because we don't // want to litter null checks everywhere, we just exit here. log.warn("gdk display is null, exiting", .{}); - std.posix.exit(1); + std.process.exit(1); }; // Setup our windowing protocol logic @@ -696,6 +710,11 @@ pub const Application = extern struct { .initial_size => return Action.initialSize(target, value), .inspector => return Action.controlInspector(target, value), + .export_terminal_io => return try Action.exportTerminalIO( + self, + target, + value, + ), .key_sequence => return Action.keySequence(target, value), .key_table => return Action.keyTable(target, value), @@ -705,10 +724,11 @@ pub const Application = extern struct { .mouse_visibility => Action.mouseVisibility(target, value), .move_tab => return Action.moveTab(target, value), + .move_tab_to_new_window => return Action.moveTabToNewWindow(target), .new_split => return Action.newSplit(target, value), - .new_tab => return Action.newTab(target), + .new_tab => return Action.newTab(target, .none), .new_window => try Action.newWindow( self, @@ -719,7 +739,7 @@ pub const Application = extern struct { .none, ), - .open_config => return Action.openConfig(self), + .open_config => return Action.openConfig(self, value), .open_url => Action.openUrl(self, value), @@ -750,6 +770,7 @@ pub const Application = extern struct { .set_title => Action.setTitle(target, value), .set_tab_title => return Action.setTabTitle(target, value), + .set_window_title => return Action.setWindowTitle(target, value), .show_child_exited => return Action.showChildExited(target, value), @@ -1016,6 +1037,38 @@ pub const Application = extern struct { \\} \\ \\/* + \\ * Drag and Drop Overlay + \\ */ + \\.drop-overlay.drop-left { + \\ background: linear-gradient( + \\ to left, + \\ transparent, 50%, + \\ color-mix(in srgb, var(--accent-bg-color), transparent 80%) 50% + \\ ); + \\} + \\.drop-overlay.drop-right { + \\ background: linear-gradient( + \\ to right, + \\ transparent, 50%, + \\ color-mix(in srgb, var(--accent-bg-color), transparent 80%) 50% + \\ ); + \\} + \\.drop-overlay.drop-top { + \\ background: linear-gradient( + \\ to top, + \\ transparent, 50%, + \\ color-mix(in srgb, var(--accent-bg-color), transparent 80%) 50% + \\ ); + \\} + \\.drop-overlay.drop-bottom { + \\ background: linear-gradient( + \\ to bottom, + \\ transparent, 50%, + \\ color-mix(in srgb, var(--accent-bg-color), transparent 80%) 50% + \\ ); + \\} + \\ + \\/* \\ * Splits \\ */ \\ @@ -1063,7 +1116,11 @@ pub const Application = extern struct { } } - fn loadCustomCss(self: *Self) (std.fs.File.ReadError || Allocator.Error)!void { + const LoadCustomCssError = std.Io.File.OpenError || + compat_file.ReadToEndAllocError || + std.mem.Allocator.Error; + + fn loadCustomCss(self: *Self) LoadCustomCssError!void { const priv: *Private = self.private(); const alloc = self.allocator(); const display = gdk.Display.getDefault() orelse { @@ -1087,7 +1144,11 @@ pub const Application = extern struct { .optional => |path| .{ path, true }, .required => |path| .{ path, false }, }; - const file = std.fs.openFileAbsolute(path, .{}) catch |err| { + const file = std.Io.Dir.openFileAbsolute( + global.io(), + path, + .{}, + ) catch |err| { if (err != error.FileNotFound or !optional) { log.warn( "error opening gtk-custom-css file {s}: {}", @@ -1096,12 +1157,14 @@ pub const Application = extern struct { } continue; }; - defer file.close(); + defer file.close(global.io()); const css_file_size_limit = 5 * 1024 * 1024; // 5MB log.info("loading gtk-custom-css path={s}", .{path}); - const contents = file.readToEndAlloc( + + const contents = compat_file.readToEndAlloc( + file, alloc, css_file_size_limit, ) catch |err| switch (err) { @@ -1111,6 +1174,7 @@ pub const Application = extern struct { }, else => |e| return e, }; + defer alloc.free(contents); const bytes = glib.Bytes.new(contents.ptr, contents.len); @@ -1141,7 +1205,8 @@ pub const Application = extern struct { fn syncActionAccelerators(self: *Self) void { self.syncActionAccelerator("app.quit", .{ .quit = {} }); - self.syncActionAccelerator("app.open-config", .{ .open_config = {} }); + self.syncActionAccelerator("app.open-config::os-open", .{ .open_config = .os_open }); + self.syncActionAccelerator("app.open-config::new-window", .{ .open_config = .new_window }); self.syncActionAccelerator("app.reload-config", .{ .reload_config = {} }); self.syncActionAccelerator("win.toggle-inspector", .{ .inspector = .toggle }); self.syncActionAccelerator("app.show-gtk-inspector", .show_gtk_inspector); @@ -1400,8 +1465,8 @@ pub const Application = extern struct { fn startupSignals(self: *Self) void { const priv = self.private(); assert(priv.signal_source == null); - priv.signal_source = glib.unixSignalAdd( - std.posix.SIG.USR2, + priv.signal_source = glibunix.signalAdd( + @intFromEnum(std.posix.SIG.USR2), handleSigusr2, self, ); @@ -1415,14 +1480,22 @@ pub const Application = extern struct { const as_variant_type = glib.VariantType.new("as"); defer as_variant_type.free(); + const tas_variant_type = glib.VariantType.new("(tas)"); + defer tas_variant_type.free(); + + const s_variant_type = glib.ext.VariantType.newFor([:0]const u8); + defer s_variant_type.free(); + const actions = [_]ext.actions.Action(Self){ .init("new-window", actionNewWindow, null), .init("new-window-command", actionNewWindow, as_variant_type), - .init("open-config", actionOpenConfig, null), + .init("new-tab", actionNewTab, tas_variant_type), + .init("open-config", actionOpenConfig, s_variant_type), .init("present-surface", actionPresentSurface, t_variant_type), .init("quit", actionQuit, null), .init("reload-config", actionReloadConfig, null), .init("toggle-quick-terminal", actionToggleQuickTerminal, null), + .init("ring-bell", actionRingBell, null), }; ext.actions.add(Self, self, &actions); @@ -1454,6 +1527,14 @@ pub const Application = extern struct { self, .{}, ); + + _ = GlobalShortcuts.signals.@"bind-failed".connect( + priv.global_shortcuts, + *Application, + globalShortcutBindFailed, + self, + .{}, + ); } fn activate(self: *Self) callconv(.c) void { @@ -1461,7 +1542,7 @@ pub const Application = extern struct { // Queue a new window const priv = self.private(); - _ = priv.core_app.mailbox.push(.{ + _ = priv.core_app.mailbox.push(global.io(), .{ .new_window = .{}, }, .{ .forever = {} }); @@ -1478,7 +1559,7 @@ pub const Application = extern struct { diag.close(); diag.unref(); // strong ref from get() } - priv.config_errors_dialog.set(null); + priv.config_errors_dialog.deinit(); if (priv.signal_source) |v| { if (glib.Source.remove(v) == 0) { log.warn("unable to remove signal source", .{}); @@ -1486,6 +1567,11 @@ pub const Application = extern struct { priv.signal_source = null; } + if (priv.bell_media) |v| { + v.unref(); + priv.bell_media = null; + } + gobject.Object.virtual_methods.dispose.call( Class.parent, self.as(Parent), @@ -1662,6 +1748,41 @@ pub const Application = extern struct { }; } + /// May fire before any window exists, hence a desktop notification + /// rather than a toast. + fn globalShortcutBindFailed( + _: *GlobalShortcuts, + failure: *const GlobalShortcuts.BindFailed, + self: *Self, + ) callconv(.c) void { + var label_buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&label_buf); + const label: []const u8 = label: { + const ok = key.labelFromTrigger(&writer, failure.trigger) catch false; + break :label if (ok) writer.buffered() else "?"; + }; + + const detail: [*:0]const u8 = detail: { + if (failure.message[0] != 0) break :detail failure.message; + break :detail if (failure.revoked) + i18n._("The keybind was revoked by the system.") + else + i18n._("The keybind was denied by the system."); + }; + + var body_buf: [512]u8 = undefined; + const body = std.fmt.bufPrintZ( + &body_buf, + "{s}: {s}", + .{ label, detail }, + ) catch return; + + Action.desktopNotification(self, .app, .{ + .title = std.mem.span(i18n._("Global keybind unavailable")), + .body = body, + }); + } + fn actionReloadConfig( _: *gio.SimpleAction, _: ?*glib.Variant, @@ -1703,93 +1824,227 @@ pub const Application = extern struct { ) callconv(.c) void { log.debug("received new window action", .{}); - var arena: std.heap.ArenaAllocator = .init(Application.default().allocator()); + var arena: std.heap.ArenaAllocator = .init(self.core().alloc); defer arena.deinit(); const alloc = arena.allocator(); - var working_directory: ?[:0]const u8 = null; - var title: ?[:0]const u8 = null; - var command: ?configpkg.Command = null; - var args: std.ArrayList([:0]const u8) = .empty; - - overrides: { + const overrides = overrides: { // were we given a parameter? - const parameter = parameter_ orelse break :overrides; + const arguments = parameter_ orelse break :overrides null; const as_variant_type = glib.VariantType.new("as"); defer as_variant_type.free(); // ensure that the supplied parameter is an array of strings - if (glib.Variant.isOfType(parameter, as_variant_type) == 0) { + if (glib.Variant.isOfType(arguments, as_variant_type) == 0) { log.warn("parameter is of type '{s}', not '{s}'", .{ - parameter.getTypeString(), + arguments.getTypeString(), as_variant_type.peekString()[0..as_variant_type.getStringLength()], }); - break :overrides; + break :overrides null; } - const s_variant_type = glib.VariantType.new("s"); - defer s_variant_type.free(); + var arguments_it: glib.VariantIter = undefined; + _ = arguments_it.init(arguments); - var it: glib.VariantIter = undefined; - _ = it.init(parameter); + break :overrides parseOverrides(alloc, &arguments_it) catch null; + }; - var e_seen: bool = false; - var i: usize = 0; + Action.newWindow( + self, + null, + if (overrides) |o| .{ + .command = o.command, + .shell_integration = o.shell_integration, + .working_directory = o.working_directory, + .title = o.title, + } else .none, + ) catch |err| { + log.warn("unable to create new window: {t}", .{err}); + }; + } - while (it.nextValue()) |value| : (i += 1) { - defer value.unref(); + /// Handle `app.new-tab` GTK action + pub fn actionNewTab( + _: *gio.SimpleAction, + parameter_: ?*glib.Variant, + self: *Self, + ) callconv(.c) void { + log.debug("received new tab action", .{}); - // just to be sure - if (value.isOfType(s_variant_type) == 0) continue; + const core_app = self.core(); - var len: usize = undefined; - const buf = value.getString(&len); - const str = buf[0..len]; + var arena: std.heap.ArenaAllocator = .init(core_app.alloc); + defer arena.deinit(); - log.debug("new-window argument: {d} {s}", .{ i, str }); + const alloc = arena.allocator(); - if (e_seen) { - const duplicated = alloc.dupeZ(u8, str) catch |err| { - log.warn("unable to duplicate argument {d} {s}: {t}", .{ i, str, err }); - break :overrides; - }; - args.append(alloc, duplicated) catch |err| { - log.warn("unable to append argument {d} {s}: {t}", .{ i, str, err }); - break :overrides; - }; - continue; - } + const surface_id_, const overrides = result: { + // were we given a parameter? + const parameter = parameter_ orelse { + log.warn("app.new-tab did not receive a parameter", .{}); + return; + }; - if (std.mem.eql(u8, str, "-e")) { - e_seen = true; - continue; - } + log.warn("got parameter: {s}", .{parameter.getTypeString()}); - if (lib.cutPrefix(u8, str, "--command=")) |v| { - var cmd: configpkg.Command = undefined; - cmd.parseCLI(alloc, v) catch |err| { - log.warn("unable to parse command: {t}", .{err}); - continue; - }; - command = cmd; - continue; - } - if (lib.cutPrefix(u8, str, "--working-directory=")) |v| { - working_directory = alloc.dupeZ(u8, std.mem.trim(u8, v, &std.ascii.whitespace)) catch |err| wd: { - log.warn("unable to duplicate working directory: {t}", .{err}); - break :wd null; - }; - continue; - } - if (lib.cutPrefix(u8, str, "--title=")) |v| { - title = alloc.dupeZ(u8, std.mem.trim(u8, v, &std.ascii.whitespace)) catch |err| t: { - log.warn("unable to duplicate title: {t}", .{err}); - break :t null; - }; + const tas_variant_type = glib.VariantType.new("(tas)"); + defer tas_variant_type.free(); + + // ensure that the supplied parameter is an array of strings + if (glib.Variant.isOfType(parameter, tas_variant_type) == 0) { + log.warn("parameter is of type '{s}', not '{s}'", .{ + parameter.getTypeString(), + tas_variant_type.peekString()[0..tas_variant_type.getStringLength()], + }); + return; + } + + var surface_id: u64 = 0; + var arguments_it_: ?*glib.VariantIter = null; + defer if (arguments_it_) |arguments_it| arguments_it.free(); + + parameter.get("(tas)", &surface_id, &arguments_it_); + + const arguments_it = arguments_it_ orelse return; + + const overrides = parseOverrides(alloc, arguments_it) catch return; + + break :result .{ if (surface_id == 0) null else surface_id, overrides }; + }; + + const surface_ = surface: { + if (surface_id_) |surface_id| find_by_id: { + break :surface core_app.findSurfaceByID(surface_id) orelse { + log.warn("new-tab: unable to find surface 0x{x:0>16}", .{surface_id}); + break :find_by_id; + }; + } + find_focused: { + break :surface core_app.focusedSurface() orelse { + log.warn("new-tab: unable to find surface", .{}); + break :find_focused; + }; + } + break :surface null; + }; + + if (surface_) |surface| { + if (!Action.newTab( + .{ + .surface = surface, + }, + .{ + .command = overrides.command, + .shell_integration = overrides.shell_integration, + .working_directory = overrides.working_directory, + .title = overrides.title, + }, + )) { + log.warn("new-tab: unable to create tab", .{}); + } + } else { + Action.newWindow( + self, + null, + .{ + .command = overrides.command, + .shell_integration = overrides.shell_integration, + .working_directory = overrides.working_directory, + .title = overrides.title, + }, + ) catch |err| { + log.warn("new-tab: unable to create new window: {t}", .{err}); + }; + } + } + + fn parseOverrides(arena_alloc: Allocator, arguments_it: *glib.VariantIter) (Allocator.Error || error{ValueRequired})!struct { + command: ?configpkg.Command = null, + shell_integration: ?configpkg.Config.ShellIntegration = null, + working_directory: ?[:0]const u8 = null, + title: ?[:0]const u8 = null, + } { + var args: std.ArrayList([:0]const u8) = .empty; + + var working_directory: ?[:0]const u8 = null; + var title: ?[:0]const u8 = null; + var command: ?configpkg.Command = null; + var parsed_shell_integration: struct { + @"shell-integration": ?configpkg.Config.ShellIntegration = null, + } = .{}; + + const s_variant_type = glib.VariantType.new("s"); + defer s_variant_type.free(); + + var e_seen: bool = false; + var i: usize = 0; + + while (arguments_it.nextValue()) |value| : (i += 1) { + defer value.unref(); + + // just to be sure + if (value.isOfType(s_variant_type) == 0) continue; + + var len: usize = undefined; + const buf = value.getString(&len); + const str = buf[0..len]; + + log.debug("argument: {d} {s}", .{ i, str }); + + if (e_seen) { + const copy = arena_alloc.dupeZ(u8, str) catch |err| { + log.warn("unable to duplicate argument {d} {s}: {t}", .{ i, str, err }); + return err; + }; + args.append(arena_alloc, copy) catch |err| { + log.warn("unable to append argument {d} {s}: {t}", .{ i, str, err }); + return err; + }; + continue; + } + + if (std.mem.eql(u8, str, "-e")) { + e_seen = true; + continue; + } + + if (std.mem.cutPrefix(u8, str, "--command=")) |v| { + var cmd: configpkg.Command = undefined; + cmd.parseCLI(arena_alloc, v) catch |err| { + log.warn("unable to parse command: {t}", .{err}); + return err; + }; + command = cmd; + continue; + } + if (std.mem.cutPrefix(u8, str, "--shell-integration=")) |v| { + cli.args.parseIntoField( + @TypeOf(parsed_shell_integration), + arena_alloc, + &parsed_shell_integration, + "shell-integration", + std.mem.trim(u8, v, &std.ascii.whitespace), + ) catch |err| { + log.warn("unable to parse shell integration {s}: {t}", .{ v, err }); continue; - } + }; + continue; + } + if (std.mem.cutPrefix(u8, str, "--working-directory=")) |v| { + working_directory = arena_alloc.dupeZ(u8, std.mem.trim(u8, v, &std.ascii.whitespace)) catch |err| { + log.warn("unable to duplicate working directory: {t}", .{err}); + return err; + }; + continue; + } + if (std.mem.cutPrefix(u8, str, "--title=")) |v| { + title = arena_alloc.dupeZ(u8, std.mem.trim(u8, v, &std.ascii.whitespace)) catch |err| { + log.warn("unable to duplicate title: {t}", .{err}); + return err; + }; + continue; } } @@ -1799,21 +2054,52 @@ pub const Application = extern struct { }; } - Action.newWindow(self, null, .{ + return .{ .command = command, + .shell_integration = parsed_shell_integration.@"shell-integration", .working_directory = working_directory, .title = title, - }) catch |err| { - log.warn("unable to create new window: {t}", .{err}); }; } pub fn actionOpenConfig( _: *gio.SimpleAction, - _: ?*glib.Variant, + parameter_: ?*glib.Variant, self: *Self, ) callconv(.c) void { - _ = self.core().mailbox.push(.open_config, .forever); + const target: CoreApp.Message.OpenConfig = target: { + const target_map: std.StaticStringMap(CoreApp.Message.OpenConfig) = .initComptime(&.{ + .{ "os-open", .os_open }, + .{ "new-window", .new_window }, + }); + + const parameter = parameter_ orelse { + log.warn("no parameter provided to app.open-config action", .{}); + break :target .os_open; + }; + + const s_variant_type = glib.ext.VariantType.newFor([:0]const u8); + defer s_variant_type.free(); + + if (parameter.isOfType(s_variant_type) == 0) { + log.warn("parameter to app.open-config action is of type '{s}' not '{s}'", .{ + parameter.getTypeString(), + s_variant_type.peekString()[0..s_variant_type.getStringLength()], + }); + break :target .os_open; + } + + var len: usize = undefined; + const buf = parameter.getString(&len); + const str = buf[0..len]; + + break :target target_map.get(str) orelse { + log.warn("'{s}' is not configured as a target for the app.open-config action", .{str}); + break :target .os_open; + }; + }; + + _ = self.core().mailbox.push(global.io(), .{ .open_config = target }, .forever); } fn actionPresentSurface( @@ -1839,6 +2125,7 @@ pub const Application = extern struct { const surface = self.core().findSurfaceByID(surface_id) orelse return; _ = self.core().mailbox.push( + global.io(), .{ .surface_message = .{ .surface = surface, @@ -1849,6 +2136,40 @@ pub const Application = extern struct { ); } + pub fn actionRingBell( + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Self, + ) callconv(.c) void { + const priv: *Private = self.private(); + const config = priv.config.get(); + + // Do our sound + if (config.@"bell-features".audio) audio: { + const config_path = config.@"bell-audio-path" orelse break :audio; + const path, const required = switch (config_path) { + .optional => |path| .{ path, false }, + .required => |path| .{ path, true }, + }; + + const volume = std.math.clamp( + config.@"bell-audio-volume", + 0.0, + 1.0, + ); + + // Reuse one MediaFile per application (rebuilt only when the path + // changes) so each bell replays the same pipeline instead of + // leaking a fresh one. Assign unconditionally: bellMediaFile frees + // any stale MediaFile and returns the current slot value (possibly + // null if the path is now inaccessible), so priv.bell_media never + // dangles. + priv.bell_media = media.bellMediaFile(priv.bell_media, path, required); + const media_file = priv.bell_media orelse break :audio; + media.playBell(media_file, volume); + } + } + //---------------------------------------------------------------- // Boilerplate/Noise @@ -1866,11 +2187,8 @@ pub const Application = extern struct { fn init(class: *Class) callconv(.c) void { // Register our compiled resources exactly once. { - const c = @cImport({ - // generated header files - @cInclude("ghostty_resources.h"); - }); - if (c.ghostty_get_resource()) |ptr| { + const ghostty_gtk_resources = @import("ghostty_gtk_resources"); + if (ghostty_gtk_resources.ghostty_get_resource()) |ptr| { gio.resourcesRegister(@ptrCast(@alignCast(ptr))); } else { // If we fail to load resources then things will @@ -1894,11 +2212,11 @@ pub const Application = extern struct { }; pub fn openUrlFallback(self: *Application, kind: apprt.action.OpenUrl.Kind, url: []const u8) void { + _ = self; // Fallback to the minimal cross-platform way of opening a URL. // This is always a safe fallback and enables for example Windows // to open URLs (GTK on Windows via WSL is a thing). internal_os.open( - self.allocator(), kind, url, ) catch |err| log.warn("unable to open url: {}", .{err}); @@ -1938,6 +2256,98 @@ const Action = struct { }; } + pub fn exportTerminalIO( + self: *Application, + target: apprt.Target, + value: apprt.Action.Value(.export_terminal_io), + ) Allocator.Error!bool { + const surface = switch (target) { + .app => return false, + .surface => |v| v.rt_surface.gobj(), + }; + + const alloc = self.allocator(); + const contents = try alloc.dupe(u8, value.contents); + errdefer alloc.free(contents); + const request = try alloc.create(ExportTerminalIORequest); + errdefer alloc.destroy(request); + request.* = .{ + .alloc = alloc, + .contents = contents, + }; + + const parent = ext.getAncestor(gtk.Window, surface.as(gtk.Widget)); + const dialog = gtk.FileChooserNative.new( + i18n._("Export Terminal IO Events"), + parent, + .save, + i18n._("Export"), + i18n._("Cancel"), + ); + const chooser = dialog.as(gtk.FileChooser); + chooser.setCreateFolders(1); + chooser.setCurrentName("ghostty-terminal-io.txt"); + + _ = gtk.NativeDialog.signals.response.connect( + dialog, + *ExportTerminalIORequest, + ExportTerminalIORequest.response, + request, + .{ .destroyData = ExportTerminalIORequest.destroy }, + ); + dialog.as(gtk.NativeDialog).show(); + return true; + } + + const ExportTerminalIORequest = struct { + alloc: Allocator, + contents: []u8, + + fn destroy(self: *ExportTerminalIORequest) callconv(.c) void { + self.alloc.free(self.contents); + self.alloc.destroy(self); + } + + fn response( + dialog: *gtk.FileChooserNative, + response_id: c_int, + self: *ExportTerminalIORequest, + ) callconv(.c) void { + defer dialog.unref(); + + if (response_id != @intFromEnum(gtk.ResponseType.accept)) return; + + const file = dialog.as(gtk.FileChooser).getFile() orelse { + log.warn("inspector export dialog returned no file", .{}); + return; + }; + defer file.unref(); + + var gerr: ?*glib.Error = null; + const replaced = file.replaceContents( + self.contents.ptr, + self.contents.len, + null, + 0, + .{}, + null, + null, + &gerr, + ); + if (gerr) |err| { + defer err.free(); + log.err( + "failed to export terminal IO events err={s}", + .{err.f_message orelse "(unknown)"}, + ); + return; + } + if (replaced == 0) { + log.err("failed to export terminal IO events", .{}); + } + } + }; + pub fn configChange( self: *Application, target: apprt.Target, @@ -2202,6 +2612,26 @@ const Action = struct { } } + pub fn moveTabToNewWindow( + target: apprt.Target, + ) bool { + switch (target) { + .app => return false, + .surface => |core| { + const surface = core.rt_surface.surface; + const window = ext.getAncestor( + Window, + surface.as(gtk.Widget), + ) orelse { + log.warn("surface is not in a window, ignoring new_tab_to_new_window", .{}); + return false; + }; + + return window.moveTabToNewWindow(surface); + }, + } + } + pub fn newSplit( target: apprt.Target, direction: apprt.action.SplitDirection, @@ -2224,7 +2654,17 @@ const Action = struct { } } - pub fn newTab(target: apprt.Target) bool { + pub fn newTab( + target: apprt.Target, + overrides: struct { + command: ?configpkg.Command = null, + shell_integration: ?configpkg.Config.ShellIntegration = null, + working_directory: ?[:0]const u8 = null, + title: ?[:0]const u8 = null, + + pub const none: @This() = .{}; + }, + ) bool { switch (target) { .app => { log.warn("new tab to app is unexpected", .{}); @@ -2243,7 +2683,12 @@ const Action = struct { log.warn("surface is not in a window, ignoring new_tab", .{}); return false; }; - window.newTab(core); + window.newTab(core, .{ + .command = overrides.command, + .shell_integration = overrides.shell_integration, + .working_directory = overrides.working_directory, + .title = overrides.title, + }); return true; }, } @@ -2254,6 +2699,7 @@ const Action = struct { parent: ?*CoreSurface, overrides: struct { command: ?configpkg.Command = null, + shell_integration: ?configpkg.Config.ShellIntegration = null, working_directory: ?[:0]const u8 = null, title: ?[:0]const u8 = null, @@ -2276,6 +2722,7 @@ const Action = struct { parent, .{ .command = overrides.command, + .shell_integration = overrides.shell_integration, .working_directory = overrides.working_directory, .title = overrides.title, }, @@ -2288,6 +2735,7 @@ const Action = struct { parent: ?*CoreSurface, overrides: struct { command: ?configpkg.Command = null, + shell_integration: ?configpkg.Config.ShellIntegration = null, working_directory: ?[:0]const u8 = null, title: ?[:0]const u8 = null, @@ -2308,6 +2756,7 @@ const Action = struct { // Create a new tab with window context (first tab in new window) win.newTabForWindow(parent, .{ .command = overrides.command, + .shell_integration = overrides.shell_integration, .working_directory = overrides.working_directory, .title = overrides.title, }); @@ -2328,18 +2777,55 @@ const Action = struct { gtk.Window.present(win.as(gtk.Window)); } - pub fn openConfig(self: *Application) bool { - // Get the config file path + pub fn openConfig(self: *Application, value: apprt.action.OpenConfig) bool { const alloc = self.allocator(); + + // Get the config file path const path = configpkg.edit.openPath(alloc) catch |err| { - log.warn("error getting config file path: {}", .{err}); + log.warn("error getting config file path: {t}", .{err}); return false; }; defer alloc.free(path); - // Open it using openURL. "path" isn't actually a URL but - // at the time of writing that works just fine for GTK. - openUrl(self, .{ .kind = .text, .url = path }); + switch (value) { + .os_open => { + // Open it using openUrl. "path" isn't actually a URL but + // at the time of writing that works just fine for GTK. + openUrl(self, .{ .kind = .text, .url = path }); + }, + + .new_window => { + const cmd = internal_os.getConfigEditCommand(alloc, path, .default) catch |err| { + log.warn("unable to get command to edit the config: {t}", .{err}); + return false; + }; + defer alloc.free(cmd); + + const command: configpkg.Command = .{ + .direct = &.{ "/bin/sh", "-c", cmd }, + }; + + const title = std.fmt.allocPrintSentinel( + alloc, + "{s} {s}", + .{ i18n._("Editing configuration file"), path }, + 0, + ) catch |err| t: { + log.warn("unable to format title: {t}", .{err}); + break :t null; + }; + defer if (title) |t| alloc.free(t); + + Action.newWindow(self, null, .{ + .command = command, + .title = title, + }) catch |err| { + log.warn("unable to create new window: {t}", .{err}); + return false; + }; + }, + } + return true; } @@ -2434,6 +2920,23 @@ const Action = struct { }, } }, + .window => { + switch (target) { + .app => return false, + .surface => |v| { + const surface = v.rt_surface.surface; + const win = ext.getAncestor( + Window, + surface.as(gtk.Widget), + ) orelse { + log.warn("surface is not in a window, ignoring prompt_window_title", .{}); + return false; + }; + win.promptWindowTitle(); + return true; + }, + } + }, } } @@ -2605,6 +3108,30 @@ const Action = struct { } } + pub fn setWindowTitle( + target: apprt.Target, + value: apprt.action.SetTitle, + ) bool { + switch (target) { + .app => { + log.warn("set_tab_title to app is unexpected", .{}); + return false; + }, + .surface => |core| { + const surface = core.rt_surface.surface; + const window = ext.getAncestor( + Window, + surface.as(gtk.Widget), + ) orelse { + log.warn("surface is not in a window, ignoring set_window_title", .{}); + return false; + }; + window.setTitleOverride(if (value.title.len == 0) null else value.title); + return true; + }, + } + } + pub fn showChildExited( target: apprt.Target, value: apprt.surface.Message.ChildExited, @@ -2846,7 +3373,7 @@ const Action = struct { /// given the runtime environment or configuration. /// /// This must be called BEFORE GTK initialization. -fn setGtkEnv(config: *const CoreConfig) error{NoSpaceLeft}!void { +fn setGtkEnv(config: *const CoreConfig) std.Io.Writer.Error!void { assert(gtk.isInitialized() == 0); var gdk_debug: struct { @@ -2863,7 +3390,7 @@ fn setGtkEnv(config: *const CoreConfig) error{NoSpaceLeft}!void { } = .{ // `gtk-opengl-debug` dumps logs directly to stderr so both must be true // to enable OpenGL debugging. - .opengl = state.logging.stderr and config.@"gtk-opengl-debug", + .opengl = global.logging().stderr and config.@"gtk-opengl-debug", }; var gdk_disable: struct { @@ -2915,8 +3442,7 @@ fn setGtkEnv(config: *const CoreConfig) error{NoSpaceLeft}!void { { var buf: [1024]u8 = undefined; - var fmt = std.io.fixedBufferStream(&buf); - const writer = fmt.writer(); + var writer: std.Io.Writer = .fixed(&buf); var first: bool = true; inline for (@typeInfo(@TypeOf(gdk_debug)).@"struct".fields) |field| { if (@field(gdk_debug, field.name)) { @@ -2926,15 +3452,14 @@ fn setGtkEnv(config: *const CoreConfig) error{NoSpaceLeft}!void { } } try writer.writeByte(0); - const value = fmt.getWritten(); + const value = writer.buffered(); log.warn("setting GDK_DEBUG={s}", .{value[0 .. value.len - 1]}); - _ = internal_os.setenv("GDK_DEBUG", value[0 .. value.len - 1 :0]); + _ = setenv("GDK_DEBUG", @ptrCast(value[0 .. value.len - 1 :0]), 1); } { var buf: [1024]u8 = undefined; - var fmt = std.io.fixedBufferStream(&buf); - const writer = fmt.writer(); + var writer: std.Io.Writer = .fixed(&buf); var first: bool = true; inline for (@typeInfo(@TypeOf(gdk_disable)).@"struct".fields) |field| { if (@field(gdk_disable, field.name)) { @@ -2944,10 +3469,13 @@ fn setGtkEnv(config: *const CoreConfig) error{NoSpaceLeft}!void { } } try writer.writeByte(0); - const value = fmt.getWritten(); + const value = writer.buffered(); log.warn("setting GDK_DISABLE={s}", .{value[0 .. value.len - 1]}); - _ = internal_os.setenv("GDK_DISABLE", value[0 .. value.len - 1 :0]); + _ = setenv("GDK_DISABLE", @ptrCast(value[0 .. value.len - 1 :0]), 1); } + + // Sync environ after altering system env + global.syncEnviron(); } fn findActiveWindow(data: ?*const anyopaque, _: ?*const anyopaque) callconv(.c) c_int { diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/clipboard_confirmation_dialog.zig b/vendor/libghostty-vt/src/apprt/gtk/class/clipboard_confirmation_dialog.zig index d44d38a3..d993b55c 100644 --- a/vendor/libghostty-vt/src/apprt/gtk/class/clipboard_confirmation_dialog.zig +++ b/vendor/libghostty-vt/src/apprt/gtk/class/clipboard_confirmation_dialog.zig @@ -193,11 +193,11 @@ pub const ClipboardConfirmationDialog = extern struct { const priv = self.private(); const req = priv.request orelse return; switch (req.*) { - .osc_52_write => { + .osc_52_write, .kitty_write => { self.as(Dialog.Parent).setHeading(i18n._("Authorize Clipboard Access")); self.as(Dialog.Parent).setBody(i18n._("An application is attempting to write to the clipboard. The current clipboard contents are shown below.")); }, - .osc_52_read => { + .osc_52_read, .kitty_read => { self.as(Dialog.Parent).setHeading(i18n._("Authorize Clipboard Access")); self.as(Dialog.Parent).setBody(i18n._("An application is attempting to read from the clipboard. The current clipboard contents are shown below.")); }, @@ -205,6 +205,7 @@ pub const ClipboardConfirmationDialog = extern struct { self.as(Dialog.Parent).setHeading(i18n._("Warning: Potentially Unsafe Paste")); self.as(Dialog.Parent).setBody(i18n._("Pasting this text into the terminal may be dangerous as it looks like some commands may be executed.")); }, + .list => unreachable, } } diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/command_palette.zig b/vendor/libghostty-vt/src/apprt/gtk/class/command_palette.zig index 6101e82e..aaec0058 100644 --- a/vendor/libghostty-vt/src/apprt/gtk/class/command_palette.zig +++ b/vendor/libghostty-vt/src/apprt/gtk/class/command_palette.zig @@ -153,7 +153,7 @@ pub const CommandPalette = extern struct { priv.source.removeAll(); const alloc = Application.default().allocator(); - var commands: std.ArrayList(*Command) = .{}; + var commands: std.ArrayList(*Command) = .empty; defer { for (commands.items) |cmd| cmd.unref(); commands.deinit(alloc); @@ -581,10 +581,8 @@ const Command = extern struct { const priv = self.private(); priv.data = .{ - .jump = .{ - // TODO: Replace with surface id whenever Ghostty adds one - .sort_key = @intFromPtr(surface), - }, + // Surface should be initialized at this point. + .jump = .{ .sort_key = surface.core().?.id }, }; priv.data.jump.surface.set(surface); @@ -611,7 +609,7 @@ const Command = extern struct { switch (priv.data) { .regular => {}, .jump => |*j| { - j.surface.set(null); + j.surface.deinit(); }, } diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/global_shortcuts.zig b/vendor/libghostty-vt/src/apprt/gtk/class/global_shortcuts.zig index 28987c25..88aaba9f 100644 --- a/vendor/libghostty-vt/src/apprt/gtk/class/global_shortcuts.zig +++ b/vendor/libghostty-vt/src/apprt/gtk/class/global_shortcuts.zig @@ -4,6 +4,7 @@ const Allocator = std.mem.Allocator; const gio = @import("gio"); const glib = @import("glib"); const gobject = @import("gobject"); +const global = @import("../../../global.zig"); const Binding = @import("../../../input.zig").Binding; const key = @import("../key.zig"); @@ -82,6 +83,24 @@ pub const GlobalShortcuts = extern struct { pub var offset: c_int = 0; }; + /// A global shortcut that failed to bind or was revoked. + /// Only valid for the duration of the signal emission. + pub const BindFailed = struct { + trigger: Binding.Trigger, + action: Binding.Action, + + /// May be empty. + message: [*:0]const u8, + + /// Revoked after binding rather than denied up front. + revoked: bool, + + pub const getGObjectType = gobject.ext.defineBoxed( + BindFailed, + .{ .name = "GhosttyGlobalShortcutsBindFailed" }, + ); + }; + pub const signals = struct { /// Emitted whenever a global shortcut is triggered. pub const trigger = struct { @@ -94,6 +113,18 @@ pub const GlobalShortcuts = extern struct { void, ); }; + + /// Emitted when a global shortcut fails to bind or is revoked. + pub const @"bind-failed" = struct { + pub const name = "bind-failed"; + pub const connect = impl.connect; + const impl = gobject.ext.defineSignal( + name, + Self, + &.{*const BindFailed}, + void, + ); + }; }; fn init(self: *Self, _: *Class) callconv(.c) void { @@ -109,6 +140,10 @@ pub const GlobalShortcuts = extern struct { fn close(self: *Self) void { const priv = self.private(); + // This is safe to call even if the winproto was already + // deinitialized, which happens during application teardown. + Application.default().winproto().clearGlobalShortcuts(); + if (priv.dbus_connection) |dbus| { if (priv.response_subscription != 0) { dbus.signalUnsubscribe(priv.response_subscription); @@ -152,10 +187,19 @@ pub const GlobalShortcuts = extern struct { const priv = self.private(); - // We need a dbus connection and configuration to proceed. - if (priv.dbus_connection == null) return; + // We need a configuration to proceed. const config = if (priv.config) |v| v.get() else return; + // Prefer a windowing protocol native mechanism over the + // XDG desktop portal when available. + if (Application.default().winproto().bindGlobalShortcuts(self, config)) { + log.debug("global shortcuts bound via winproto", .{}); + return; + } + + // The portal fallback requires a dbus connection. + if (priv.dbus_connection == null) return; + // Setup our new arena that we'll use for memory allocations. assert(priv.arena == null); var arena: std.heap.ArenaAllocator = .init(Application.default().allocator()); @@ -558,10 +602,25 @@ pub const GlobalShortcuts = extern struct { log.debug("activated={s}", .{shortcut_id}); const action = self.private().map.get(std.mem.span(shortcut_id)) orelse return; + self.emitTrigger(&action); + } + + /// Emit the trigger signal. Public for winproto shortcut backends. + pub fn emitTrigger(self: *Self, action: *const Binding.Action) void { signals.trigger.impl.emit( self, null, - .{&action}, + .{action}, + null, + ); + } + + /// Emit the bind-failed signal. Public for winproto shortcut backends. + pub fn emitBindFailed(self: *Self, failure: *const BindFailed) void { + signals.@"bind-failed".impl.emit( + self, + null, + .{failure}, null, ); } @@ -611,6 +670,7 @@ pub const GlobalShortcuts = extern struct { // Signals signals.trigger.impl.register(.{}); + signals.@"bind-failed".impl.register(.{}); // Virtual methods gobject.Object.virtual_methods.dispose.implement(class, &dispose); @@ -630,6 +690,9 @@ fn generateToken(buf: *Token) [:0]const u8 { return std.fmt.bufPrintZ( buf, "ghostty_{x:0<7}", - .{std.crypto.random.int(u28)}, + .{rand_int: { + const rng_impl: std.Random.IoSource = .{ .io = global.io() }; + break :rand_int rng_impl.interface().int(u28); + }}, ) catch unreachable; } diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/imgui_widget.zig b/vendor/libghostty-vt/src/apprt/gtk/class/imgui_widget.zig index 0ef753a8..50247fbf 100644 --- a/vendor/libghostty-vt/src/apprt/gtk/class/imgui_widget.zig +++ b/vendor/libghostty-vt/src/apprt/gtk/class/imgui_widget.zig @@ -7,6 +7,7 @@ const adw = @import("adw"); const gdk = @import("gdk"); const gobject = @import("gobject"); const gtk = @import("gtk"); +const global = @import("../../../global.zig"); const input = @import("../../../input.zig"); const gresource = @import("../build/gresource.zig"); @@ -61,13 +62,13 @@ pub const ImguiWidget = extern struct { ig_context: ?*cimgui.c.ImGuiContext = null, /// Our previous instant used to calculate delta time for animations. - instant: ?std.time.Instant = null, + instant: ?std.Io.Timestamp = null, /// Tick callback ID for timed updates. tick_callback_id: c_uint = 0, /// Last render time for throttling to 30 FPS. - last_render_time: ?std.time.Instant = null, + last_render_time: ?std.Io.Timestamp = null, pub var offset: c_int = 0; }; @@ -140,10 +141,11 @@ pub const ImguiWidget = extern struct { const priv = self.private(); const io: *cimgui.c.ImGuiIO = cimgui.c.ImGui_GetIO(); + const now: std.Io.Timestamp = .now(global.io(), .awake); + // Determine our delta time - const now = std.time.Instant.now() catch unreachable; io.DeltaTime = if (priv.instant) |prev| delta: { - const since_ns: f64 = @floatFromInt(now.since(prev)); + const since_ns: f64 = @floatFromInt(prev.durationTo(now).nanoseconds); const ns_per_s: f64 = @floatFromInt(std.time.ns_per_s); const since_s: f32 = @floatCast(since_ns / ns_per_s); break :delta @max(0.00001, since_s); @@ -298,7 +300,7 @@ pub const ImguiWidget = extern struct { // Update last render time for tick callback throttling. const priv = self.private(); - priv.last_render_time = std.time.Instant.now() catch null; + priv.last_render_time = .now(global.io(), .awake); // Setup our frame. We render twice because some ImGui behaviors // take multiple renders to process. I don't know how to make this @@ -457,15 +459,10 @@ pub const ImguiWidget = extern struct { const self: *Self = gobject.ext.cast(Self, widget) orelse return 0; const priv = self.private(); - const now = std.time.Instant.now() catch { - self.queueRender(); - return 1; - }; - // Throttle to 30 FPS (~33ms between frames) const frame_time_ns: u64 = std.time.ns_per_s / 30; const should_render = if (priv.last_render_time) |last| - now.since(last) >= frame_time_ns + last.untilNow(global.io(), .awake).nanoseconds >= frame_time_ns else true; diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/split_tree.zig b/vendor/libghostty-vt/src/apprt/gtk/class/split_tree.zig index 7a6b66de..ca743712 100644 --- a/vendor/libghostty-vt/src/apprt/gtk/class/split_tree.zig +++ b/vendor/libghostty-vt/src/apprt/gtk/class/split_tree.zig @@ -214,6 +214,7 @@ pub const SplitTree = extern struct { parent_: ?*Surface, overrides: struct { command: ?configpkg.Command = null, + shell_integration: ?configpkg.Config.ShellIntegration = null, working_directory: ?[:0]const u8 = null, title: ?[:0]const u8 = null, @@ -225,6 +226,7 @@ pub const SplitTree = extern struct { // Create our new surface. const surface: *Surface = .new(.{ .command = overrides.command, + .shell_integration = overrides.shell_integration, .working_directory = overrides.working_directory, .title = overrides.title, }); @@ -239,12 +241,7 @@ pub const SplitTree = extern struct { } // Bind is-split property for new surface - _ = self.as(gobject.Object).bindProperty( - "is-split", - surface.as(gobject.Object), - "is-split", - .{ .sync_create = true }, - ); + surface.bindIsSplit(self); // Create our tree var single_tree = try Surface.Tree.init(alloc, surface); @@ -387,6 +384,77 @@ pub const SplitTree = extern struct { return true; } + /// Move the source split onto the target split in a given direction. + /// The target split must be located within this tree. + pub fn moveSplit( + self: *Self, + source: *Surface, + target: *Surface, + dir: Surface.Tree.Split.Direction, + ) Allocator.Error!void { + const alloc = Application.default().allocator(); + const target_tree = self.getTree() orelse return; + + // This really shouldn't fail, but just in case + const target_handle = target_tree.locate(target) orelse { + log.warn("target is not placed in a split tree", .{}); + return; + }; + + // Try to find the source within the current tree. + // If it exists, then it's a local move; otherwise the logic gets more + // complicated + const source_handle = target_tree.locate(source); + + // First add the source into this tree. + // We have to do this no matter what + var branch = try Surface.Tree.init(alloc, source); + defer branch.deinit(); + + var after_split = try target_tree.split( + alloc, + target_handle, + dir, + 0.5, + &branch, + ); + defer after_split.deinit(); + + if (source_handle) |handle| { + // Happy path: source and target are located within the same tree. + // In that case, we just remove the existing source from this tree + + var after_remove = try after_split.remove(alloc, handle); + defer after_remove.deinit(); + + self.setTree(&after_remove); + } else { + // :( Cross-tree moves are a bit more complicated. + + // TODO: Find a better way to access the split tree from here + const source_tree_widget = ext.getAncestor( + SplitTree, + source.as(gtk.Widget), + ) orelse { + log.warn("source is not placed in a split tree", .{}); + return; + }; + const source_tree = source_tree_widget.getTree() orelse return; + + // Remove the source from its own tree + const handle = source_tree.locate(source) orelse return; + var new_source_tree = try source_tree.remove(alloc, handle); + defer new_source_tree.deinit(); + + // Finally, set the final tree structures for both tree widgets + source_tree_widget.setTree(&new_source_tree); + self.setTree(&after_split); + + // Re-bind vital properties like `is-split` + source.bindIsSplit(self); + } + } + fn disconnectSurfaceHandlers(self: *Self) void { const tree = self.getTree() orelse return; var it = tree.iterator(); @@ -579,7 +647,7 @@ pub const SplitTree = extern struct { fn dispose(self: *Self) callconv(.c) void { const priv = self.private(); - priv.last_focused.set(null); + priv.last_focused.deinit(); if (priv.rebuild_source) |v| { if (glib.Source.remove(v) == 0) { log.warn("unable to remove rebuild source", .{}); @@ -705,17 +773,8 @@ pub const SplitTree = extern struct { // Find the surface in the tree to verify this is valid and // set our pending close handle. - priv.pending_close = handle: { - const tree = self.getTree() orelse return; - var it = tree.iterator(); - while (it.next()) |entry| { - if (entry.view == surface) { - break :handle entry.handle; - } - } - - return; - }; + const tree = self.getTree() orelse return; + priv.pending_close = tree.locate(surface) orelse return; // If we don't need to confirm then just close immediately. if (!core.needsConfirmQuit()) { @@ -1115,12 +1174,17 @@ const SplitTreeSplit = extern struct { /// Assumed to be correct. handle: Surface.Tree.Node.Handle, + /// Cache the split ratio. Looking it up is relatively expensive + /// because we have to first walk the widget tree to find the + /// SplitTree widget. + ratio: f64, + /// Source to handle repositioning the split when properties change. idle: ?c_uint = null, /// Whether the max-position/position property of the gtk.Paned widget /// changed. We use these to distinguish between a resize and the user - /// manually moving the split divider. See the "on-idle" function. + /// manually moving the split divider. See the "onIdle" function. max_changed: bool = false, pos_changed: bool = false, @@ -1144,6 +1208,7 @@ const SplitTreeSplit = extern struct { const self = gobject.ext.newInstance(Self, .{}); const priv = self.private(); priv.handle = handle; + priv.ratio = split.ratio; // Setup our paned fields const paned = priv.paned; @@ -1154,6 +1219,17 @@ const SplitTreeSplit = extern struct { .vertical => .vertical, }); + // Request min width/height 1 for surfaces to prevent them from + // becoming temporarily invisible in nested layouts. Otherwise + // gtk.Paned might mark a surface as not visible and unmap it + // for a single frame. See the comments below in propMaxPosition. + if (gobject.ext.isA(start_child, SurfaceScrolledWindow)) { + start_child.setSizeRequest(1, 1); + } + if (gobject.ext.isA(end_child, SurfaceScrolledWindow)) { + end_child.setSizeRequest(1, 1); + } + // Signals and so on are setup in the template. return self; @@ -1163,49 +1239,18 @@ const SplitTreeSplit = extern struct { gtk.Widget.initTemplate(self.as(gtk.Widget)); } - // We need to keep the split ratios from the tree datastructure and - // widget tree in sync. Using the max-position and position properties - // of the gtk.Paned widget, we can distinguish a resize from a manual - // update (e.g. the user dragging the divider).If max-position changes, - // we always have a widget resize. Usually position will change as well - // but it might not if the size change is small enough. If only position - // changes, we have a manual human update. - // - // This is a hack, it relies on the timing of property notifcations. - // From looking at the GTK source code, it should not be possible that - // we interpret a position change from a resize as a manual update. - // When a gtk.Paned is resized, internally the gtk_paned_calc_position - // function will change both max-position and position and synchronously - // call our propMaxPosition and propPosition functions. I.e. when the - // widget is resized, it should not be possible for onIdle to run before - // we have been notified of both property changes. - fn onIdle(ud: ?*anyopaque) callconv(.c) c_int { - const self: *Self = @ptrCast(@alignCast(ud orelse return 0)); + const SyncDirection = enum { + // Update gtk.Paned widget to match ratio from split tree node. + tree_to_widget, + // Update split tree node to match ratio from gtk.Paned widget. + widget_to_tree, + }; + + // Sync split ratio between the gtk.Paned widget and the split tree. + fn syncSplitRatio(self: *Self, direction: SyncDirection) void { const priv = self.private(); const paned = priv.paned; - // Clear source and fields at the end. Otherwise if setPosition is - // called below, propPosition is triggered and would add another - // idle callback before this one is finished. - defer priv.idle = null; - defer priv.max_changed = false; - defer priv.pos_changed = false; - - if (!priv.max_changed and !priv.pos_changed) { - return 0; - } - - // Get our split. This is the most dangerous part of this entire - // widget. We assume that this widget is always a child of a - // SplitTree, we assume that our handle is valid, and we assume - // the handle is always a split node. - const split_tree = ext.getAncestor( - SplitTree, - self.as(gtk.Widget), - ) orelse return 0; - const tree = split_tree.getTree() orelse return 0; - const split: *const Surface.Tree.Split = &tree.nodes[priv.handle.idx()].split; - // Current, min, and max positions as pixels. const pos = paned.getPosition(); const min = min: { @@ -1236,7 +1281,7 @@ const SplitTreeSplit = extern struct { // If our max is zero then we can't do any math. I don't know // if this is possible but I suspect it can be if you make a nested // split completely minimized. - if (max == 0) return 0; + if (max == 0) return; // Determine our current ratio. const current_ratio: f64 = ratio: { @@ -1244,7 +1289,7 @@ const SplitTreeSplit = extern struct { const max_f64: f64 = @floatFromInt(max); break :ratio pos_f64 / max_f64; }; - const desired_ratio: f64 = @floatCast(split.ratio); + const desired_ratio: f64 = priv.ratio; // If our ratio is close enough to our desired ratio, then // we ignore the update. This is to avoid constant split updates @@ -1255,24 +1300,85 @@ const SplitTreeSplit = extern struct { desired_ratio, 0.001, )) { - return 0; + return; + } + + switch (direction) { + .tree_to_widget => { + // Update position in gtk.Paned to match desired ratio. Note that if + // max-position is small, it might not be possible to accurately set + // the desired ratio. E.g. with max-position=2 you can only have + // ratios 0, 0.5 and 1. + const desired_pos: c_int = desired_pos: { + const max_f64: f64 = @floatFromInt(max); + break :desired_pos @intFromFloat(@round(max_f64 * desired_ratio)); + }; + paned.setPosition(desired_pos); + }, + .widget_to_tree => { + // Update ratio of the split tree node. This is the most dangerous + // part of this widget. We assume that this widget is always a child + // of a SplitTree, we assume that our handle is valid, and we assume + // the handle is always a split node. + const split_tree = ext.getAncestor( + SplitTree, + self.as(gtk.Widget), + ) orelse { + log.warn("SplitTree ancestor not found", .{}); + return; + }; + const tree = split_tree.getTree() orelse { + log.warn("no tree data model set", .{}); + return; + }; + assert(priv.handle.idx() < tree.nodes.len); + assert(tree.nodes[priv.handle.idx()] == .split); + tree.resizeInPlace(priv.handle, @floatCast(current_ratio)); + priv.ratio = current_ratio; + }, + } + } + + // We need to keep the split ratios from the tree datastructure and + // widget tree in sync. Using the max-position and position properties + // of the gtk.Paned widget, we can distinguish a resize from a manual + // update (e.g. the user dragging the divider). If max-position changes, + // we always have a widget resize and we sync the ratio directly from + // the propMaxPosition function. Usually position will change as well + // but it might not if the size change is small enough. If only position + // changes, we have a manual human update and we sync the ratio here. + // + // This is a hack, it relies on the timing of property notifcations. + // From looking at the GTK source code, it should not be possible that + // we interpret a position change from a resize as a manual update. + // When a gtk.Paned is resized, internally the gtk_paned_calc_position + // function will change both max-position and position and synchronously + // call our propMaxPosition and propPosition functions. I.e. when the + // widget is resized, it should not be possible for onIdle to run before + // we have been notified of both property changes. + fn onIdle(ud: ?*anyopaque) callconv(.c) c_int { + const self: *Self = @ptrCast(@alignCast(ud orelse return 0)); + const priv = self.private(); + + // Clear source and fields. + defer { + priv.idle = null; + priv.max_changed = false; + priv.pos_changed = false; } if (priv.max_changed) { - // Widget got resized, update position to match desired ratio. - // Note that if max-position is small, it might not be possible - // to accurately set the desired ratio. E.g. with max-position=2 - // you can only have ratios 0, 0.5 and 1. - const desired_pos: c_int = desired_pos: { - const max_f64: f64 = @floatFromInt(max); - break :desired_pos @intFromFloat(@round(max_f64 * desired_ratio)); - }; - paned.setPosition(desired_pos); - } else { - // If only position changed, this is a manual human update and - // we need to write our update back to the tree. - tree.resizeInPlace(priv.handle, @floatCast(current_ratio)); + // Nothing to do, syncSplitRatio was already called + // directly from propMaxPosition. + return 0; + } + if (!priv.pos_changed) { + return 0; } + + // If only position changed, this is a manual human update and + // we need to write our update back to the tree. + self.syncSplitRatio(.widget_to_tree); return 0; } @@ -1286,6 +1392,41 @@ const SplitTreeSplit = extern struct { ) callconv(.c) void { const priv = self.private(); priv.max_changed = true; + + // Widget got resized, update position to match desired ratio. + // + // Syncing the split ratio directly here ensures that every surface + // gets the correct size allocated immediately. This works because + // this callback runs during size allocation, so that we update the + // divider position right after the gtk.Paned widget computes it and + // right before it will use the position to compute the sizes of its + // children. See the size_allocate and calc_position functions of the + // gtk.Paned widget. + // + // If we synced the ratio in an idle callback, there might be some + // flickering as surfaces are shown with the wrong size for a few + // frames until our idle callback runs. Depending on the split layout + // it might take multiple rounds of size allocation and idle callbacks + // for every widget to get the correct size. + // + // Note that gtk.Paned calls Widget.setChildVisible(true/false) for + // both children based on the initial divider position it computes. + // If we then update the position here, we might sometimes have to + // call setChildVisible again. This would be necessary if we updated + // the position from initially 0 (max), where start (end) child is + // invisible, to a value > 0 (< max). However, we can skip this because + // set_position already queues another round of size allocation, during + // which gtk.Paned will keep the correct position we set manually and + // then call setChildVisible again based on that. The only problematic + // situation is when we update the position from 0 to a value > 0, + // because setChildVisible(false) will unmap the start child widget. + // It will not be visible for a single frame which introduces some + // flickering. To prevent this from happening we set a min size request + // of 1 for all surfaces (see the "new" function above). + self.syncSplitRatio(.tree_to_widget); + + // We still need the idle callback to clear the max_changed field + // later, because propPosition might or might not still get called. if (priv.idle == null) priv.idle = glib.idleAdd( onIdle, self, diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/surface.zig b/vendor/libghostty-vt/src/apprt/gtk/class/surface.zig index 54825ce0..9d45bc6c 100644 --- a/vendor/libghostty-vt/src/apprt/gtk/class/surface.zig +++ b/vendor/libghostty-vt/src/apprt/gtk/class/surface.zig @@ -34,8 +34,10 @@ const ClipboardConfirmationDialog = @import("clipboard_confirmation_dialog.zig") const TitleDialog = @import("title_dialog.zig").TitleDialog; const Window = @import("window.zig").Window; const InspectorWindow = @import("inspector_window.zig").InspectorWindow; +const SplitTree = @import("split_tree.zig").SplitTree; const i18n = @import("../../../os/i18n.zig"); -const media = @import("../media.zig"); +const global = @import("../../../global.zig"); +const gtk_version = @import("../gtk_version.zig"); const log = std.log.scoped(.gtk_ghostty_surface); @@ -674,12 +676,6 @@ pub const Surface = extern struct { // false by a parent widget. bell_ringing: bool = false, - // The audio bell's MediaFile, reused across bells so we don't leak a - // GStreamer pipeline (and its GL threads) on every ring. Built lazily - // on the first audio bell and rebuilt when `bell-audio-path` changes; - // unref'd on dispose. See ringBell and media.zig. - bell_media: ?*gtk.MediaFile = null, - /// True if this surface is in an error state. This is currently /// a simple boolean with no additional information on WHAT the /// error state is, because we don't yet need it or use it. For now, @@ -695,6 +691,7 @@ pub const Surface = extern struct { // True if the current surface is a split, this is used to apply // unfocused-split-* options is_split: bool = false, + is_split_binding: ?*gobject.Binding = null, action_group: ?*gio.SimpleActionGroup = null, @@ -713,6 +710,9 @@ pub const Surface = extern struct { child_exited_overlay: *ChildExited, context_menu: *gtk.PopoverMenu, drop_target: *gtk.DropTarget, + surface_drop_target: *gtk.DropTarget, + drag_handle: *gtk.Widget, + drop_overlay: *gtk.Widget, progress_bar_overlay: *gtk.ProgressBar, error_page: *adw.StatusPage, terminal_page: *gtk.Overlay, @@ -736,6 +736,7 @@ pub const Surface = extern struct { overrides: struct { command: ?configpkg.Command = null, + shell_integration: ?configpkg.Config.ShellIntegration = null, working_directory: ?[:0]const u8 = null, pub const none: @This() = .{}; @@ -746,6 +747,7 @@ pub const Surface = extern struct { pub fn new(overrides: struct { command: ?configpkg.Command = null, + shell_integration: ?configpkg.Config.ShellIntegration = null, working_directory: ?[:0]const u8 = null, title: ?[:0]const u8 = null, @@ -758,6 +760,7 @@ pub const Surface = extern struct { const priv: *Private = self.private(); priv.overrides = .{ .command = if (overrides.command) |c| c.clone(alloc) catch null else null, + .shell_integration = overrides.shell_integration, .working_directory = if (overrides.working_directory) |wd| alloc.dupeZ(u8, wd) catch null else null, }; return self; @@ -848,6 +851,28 @@ pub const Surface = extern struct { return @intFromBool(config.@"bell-features".border); } + pub fn bindIsSplit(self: *Self, tree: *SplitTree) void { + const priv = self.private(); + if (priv.is_split_binding) |binding| { + binding.unbind(); + binding.unref(); + priv.is_split_binding = null; + } + + const binding = tree.as(gobject.Object).bindProperty( + "is-split", + self.as(gobject.Object), + "is-split", + .{ .sync_create = true }, + ); + // The ref created by bindProperty is owned by the binding itself. + // We need another ref to prevent the binding object from being + // freed if the source object (SplitTree) is finalized. Otherwise + // our pointer to the binding could become stale. + binding.ref(); + priv.is_split_binding = binding; + } + /// Callback used to determine whether unfocused-split-fill / unfocused-split-opacity /// should be applied to the surface fn closureShouldUnfocusedSplitBeShown( @@ -1363,19 +1388,11 @@ pub const Surface = extern struct { if (entry.native == keycode) break :w3c entry.key; } else .unidentified; - // Consult the pre-remapped XKB keyval/keysym to get the (possibly) - // remapped key. If the W3C key or the remapped key - // is eligible for remapping, we use it. - // - // See the docs for `shouldBeRemappable` for why we even have to - // do this in the first place. - if (gtk_key.keyFromKeyval(keyval)) |remapped| { - if (w3c_key.shouldBeRemappable() or remapped.shouldBeRemappable()) - break :keycode remapped; - } - - // Return the original physical key - break :keycode w3c_key; + break :keycode gtk_key.remapKey( + w3c_key, + keyval, + key_event.isModifier() != 0, + ); }; // Get our modifier for the event @@ -1560,7 +1577,11 @@ pub const Surface = extern struct { // https://gitlab.gnome.org/GNOME/libadwaita/-/commit/a7738a4d269bfdf4d8d5429ca73ccdd9b2450421 // https://gitlab.gnome.org/GNOME/libadwaita/-/commit/9759d3fd81129608dd78116001928f2aed974ead if (gtk_xft_dpi <= 0) { - log.warn("gtk-xft-dpi has invalid value ({}), using default", .{gtk_xft_dpi}); + // -1 is a valid value which specifies default scale. + // https://docs.gtk.org/gtk4/property.Settings.gtk-xft-dpi.html + if (gtk_xft_dpi != -1) { + log.warn("gtk-xft-dpi has invalid value ({}), using default", .{gtk_xft_dpi}); + } break :xft_scale 1.0; } @@ -1589,32 +1610,35 @@ pub const Surface = extern struct { return self.private().cursor_pos; } - pub fn defaultTermioEnv(self: *Self) !std.process.EnvMap { + pub fn defaultTermioEnv(self: *Self) !std.process.Environ.Map { const app = Application.default(); const alloc = app.allocator(); - var env = try internal_os.getEnvMap(alloc); + var env = if (internal_os.isFlatpak()) + std.process.Environ.Map.init(alloc) + else + try global.environMap(); errdefer env.deinit(); if (app.savedLanguage()) |language| { try env.put("LANG", language); } else { - env.remove("LANG"); + _ = env.orderedRemove("LANG"); } // Don't leak these GTK environment variables to child processes. - env.remove("GDK_DEBUG"); - env.remove("GDK_DISABLE"); - env.remove("GSK_RENDERER"); + _ = env.orderedRemove("GDK_DEBUG"); + _ = env.orderedRemove("GDK_DISABLE"); + _ = env.orderedRemove("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"); - env.remove("NOTIFY_SOCKET"); + _ = env.orderedRemove("GIO_LAUNCHED_DESKTOP_FILE"); + _ = env.orderedRemove("GIO_LAUNCHED_DESKTOP_FILE_PID"); + _ = env.orderedRemove("DBUS_STARTER_ADDRESS"); + _ = env.orderedRemove("DBUS_STARTER_BUS_TYPE"); + _ = env.orderedRemove("INVOCATION_ID"); + _ = env.orderedRemove("JOURNAL_STREAM"); + _ = env.orderedRemove("NOTIFY_SOCKET"); // Unset environment varies set by snaps if we're running in a snap. // This allows Ghostty to further launch additional snaps. @@ -1641,7 +1665,7 @@ pub const Surface = extern struct { } /// Filter out environment variables that start with forbidden prefixes. - fn filterSnapPaths(gpa: std.mem.Allocator, env_map: *std.process.EnvMap) !void { + fn filterSnapPaths(gpa: std.mem.Allocator, env_map: *std.process.Environ.Map) !void { comptime assert(build_config.snap); const snap_vars = [_][]const u8{ @@ -1708,14 +1732,14 @@ pub const Surface = extern struct { item.key, item.value, ); - for (env_to_remove.items) |key| _ = env_map.remove(key); + for (env_to_remove.items) |key| _ = env_map.orderedRemove(key); } pub fn clipboardRequest( self: *Self, clipboard_type: apprt.Clipboard, state: apprt.ClipboardRequest, - ) !bool { + ) !apprt.ClipboardReadResult { return try Clipboard.request( self, clipboard_type, @@ -1825,8 +1849,19 @@ pub const Surface = extern struct { }; priv.drop_target.setGtypes(&drop_target_types, drop_target_types.len); + // Also have to set up the surface drop target to accept other surfaces + // (in particular, their surface IDs) + var surface_drop_target_types = [_]gobject.Type{ + gobject.ext.types.uint64, + }; + priv.surface_drop_target.setGtypes( + &surface_drop_target_types, + surface_drop_target_types.len, + ); + // Setup properties we can't set from our Blueprint file. self.as(gtk.Widget).setCursorFromName("text"); + priv.drag_handle.setCursorFromName("grab"); // Initialize our config self.propConfig(undefined, null); @@ -1860,9 +1895,10 @@ pub const Surface = extern struct { priv.config = null; } - if (priv.bell_media) |v| { - v.unref(); - priv.bell_media = null; + if (priv.is_split_binding) |binding| { + binding.unbind(); + binding.unref(); + priv.is_split_binding = null; } if (priv.vadj_signal_group) |group| { @@ -1987,6 +2023,58 @@ pub const Surface = extern struct { ); } + fn snapshot(self: *Self, snap: *gtk.Snapshot) callconv(.c) void { + const priv = self.private(); + + const blur = blur: { + // Native GTK blur is only available since GTK 4.23.3 + if (gtk_version.runtimeUntil(4, 23, 3)) break :blur null; + const config = priv.config orelse break :blur null; + + break :blur switch (config.get().@"background-blur") { + .radius => |v| @as(f32, v), + .true, .@"macos-glass-regular", .@"macos-glass-clear" => 20.0, + .false => null, + }; + }; + + if (blur) |b| blur: { + // pushCopy and appendPaste are only supported since 4.22. + // These two functions are crucial for the blur function + // and it cannot be implemented otherwise, so if you compile + // Ghostty on an older GTK version, you won't get blur. + // Sorry. + if (comptime !gtk_version.atLeast(4, 22, 0)) break :blur; + + const width = self.as(gtk.Widget).getWidth(); + const height = self.as(gtk.Widget).getHeight(); + + // Push the current render state (background) + // to be appended below + snap.pushCopy(); + defer snap.pop(); + + // Apply blur to the copied background + snap.pushBlur(b); + defer snap.pop(); + + snap.appendPaste(&.{ + .f_origin = .{ .f_x = 0, .f_y = 0 }, + .f_size = .{ + .f_width = @floatFromInt(width), + .f_height = @floatFromInt(height), + }, + }, 0); + } + + // Draw the children normally + gtk.Widget.virtual_methods.snapshot.call( + Class.parent, + self.as(Parent), + snap, + ); + } + //--------------------------------------------------------------- // Properties @@ -2213,7 +2301,12 @@ pub const Surface = extern struct { // Logic around bell reaction happens on every event even if we're // already in the ringing state. - if (ringing) self.ringBell(); + if (ringing) { + self.ringBell(); + // focus clears ringing state, so we should not change state to + // ringing if we're already focused + if (self.getFocused()) return; + } // Property change only happens on actual state change const priv = self.private(); @@ -2467,8 +2560,6 @@ pub const Surface = extern struct { /// Handle bell features that need to happen every time a BEL is received /// Currently this is audio and system but this could change in the future. fn ringBell(self: *Self) void { - const priv = self.private(); - // Emit the signal signals.bell.impl.emit( self, @@ -2480,33 +2571,7 @@ pub const Surface = extern struct { // Activate actions if they exist _ = self.as(gtk.Widget).activateAction("tab.ring-bell", null); _ = self.as(gtk.Widget).activateAction("win.ring-bell", null); - - const config = if (priv.config) |c| c.get() else return; - - // Do our sound - if (config.@"bell-features".audio) audio: { - const config_path = config.@"bell-audio-path" orelse break :audio; - const path, const required = switch (config_path) { - .optional => |path| .{ path, false }, - .required => |path| .{ path, true }, - }; - - const volume = std.math.clamp( - config.@"bell-audio-volume", - 0.0, - 1.0, - ); - - // Reuse one MediaFile per surface (rebuilt only when the path - // changes) so each bell replays the same pipeline instead of - // leaking a fresh one. Assign unconditionally: bellMediaFile frees - // any stale MediaFile and returns the current slot value (possibly - // null if the path is now inaccessible), so priv.bell_media never - // dangles. - priv.bell_media = media.bellMediaFile(priv.bell_media, path, required); - const media_file = priv.bell_media orelse break :audio; - media.playBell(media_file, volume); - } + _ = self.as(gtk.Widget).activateAction("app.ring-bell", null); } //--------------------------------------------------------------- @@ -2815,7 +2880,10 @@ pub const Surface = extern struct { return; } - if (button == .middle and !priv.gtk_enable_primary_paste) { + if (button == .middle and + !priv.gtk_enable_primary_paste and + !core_surface.mouseReportingActive()) + { return; } @@ -2875,7 +2943,10 @@ pub const Surface = extern struct { return; } - if (button == .middle and !priv.gtk_enable_primary_paste) { + if (button == .middle and + !priv.gtk_enable_primary_paste and + !surface.mouseReportingActive()) + { return; } @@ -3320,7 +3391,7 @@ pub const Surface = extern struct { self: *Self, ) callconv(.c) void { self.updateMapped(true); - self.updateOcclusion(true); + self.updateOcclusion(); } fn glareaUnmap( @@ -3328,7 +3399,7 @@ pub const Surface = extern struct { self: *Self, ) callconv(.c) void { self.updateMapped(false); - self.updateOcclusion(false); + self.updateOcclusion(); } fn updateMapped(self: *Self, mapped: bool) void { @@ -3337,13 +3408,23 @@ pub const Surface = extern struct { self.as(gobject.Object).notifyByPspec(properties.mapped.impl.param_spec); } - fn updateOcclusion(self: *Self, visible: bool) void { + /// Update the core surface visibility based on both GTK widget and + /// toplevel state. This is public so the window can call it when its + /// suspended state changes. + pub fn updateOcclusion(self: *Self) void { const surface = self.core() orelse return; + const visible = self.private().mapped and !self.windowSuspended(); surface.occlusionCallback(visible) catch |err| { log.warn("error in occlusion callback err={}", .{err}); }; } + fn windowSuspended(self: *Self) bool { + const native = self.as(gtk.Widget).getNative() orelse return false; + const window = gobject.ext.cast(gtk.Window, native) orelse return false; + return window.isSuspended() != 0; + } + fn glareaRender( _: *gtk.GLArea, _: *gdk.GLContext, @@ -3461,9 +3542,11 @@ pub const Surface = extern struct { ); defer config.deinit(); - if (priv.overrides.command) |c| { - config.command = try c.clone(config._arena.?.allocator()); - } + try applyCommandOverrides( + &config, + priv.overrides.command, + priv.overrides.shell_integration, + ); if (priv.overrides.working_directory) |wd| { const config_alloc = config.arenaAlloc(); var wd_val: configpkg.WorkingDirectory = .{ .path = try config_alloc.dupe(u8, wd) }; @@ -3597,6 +3680,178 @@ pub const Surface = extern struct { }; } + fn closureShouldDragHandleBeShown( + _: *Self, + config_: ?*Config, + is_split: c_int, + ) callconv(.c) c_int { + const config = config_ orelse return @intFromBool(false); + + const shown = switch (config.get().@"drag-handle") { + .always => true, + .auto => is_split != 0, + .never => false, + }; + return @intFromBool(shown); + } + + fn surfaceDragPrepare( + src: *gtk.DragSource, + x: f64, + y: f64, + self: *Self, + ) callconv(.c) *gdk.ContentProvider { + // TODO: Use a static content provider once we make `self.core()` + // available immediately when constructing the surface widget + _ = src; + _ = x; + _ = y; + var val = gobject.ext.Value.newFrom(self.core().?.id); + return gdk.ContentProvider.newForValue(&val); + } + + fn surfaceDragBegin( + src: *gtk.DragSource, + _: *gdk.Drag, + self: *Self, + ) callconv(.c) void { + // The scale of the preview + const preview_scale: f32 = 0.2; + + // Snapshot the entire surface widget as the icon preview + const paintable = gtk.WidgetPaintable.new(self.as(gtk.Widget)); + defer paintable.unref(); + + // Center the preview + const width = self.as(gtk.Widget).getWidth(); + const height = self.as(gtk.Widget).getHeight(); + const mid_x = @as(f32, @floatFromInt(width)) / 2; + const mid_y = @as(f32, @floatFromInt(height)) / 2; + + // Create a snapshot to render the scaled paintable + const snap = gtk.Snapshot.new(); + snap.scale(preview_scale, preview_scale); + paintable.as(gdk.Paintable).snapshot( + snap.as(gdk.Snapshot), + @floatFromInt(width), + @floatFromInt(height), + ); + + if (snap.freeToPaintable(null)) |scaled| { + defer scaled.unref(); + src.setIcon( + scaled, + @intFromFloat(mid_x * preview_scale), + @intFromFloat(mid_y * preview_scale), + ); + } else { + // The scaling process somehow failed. + // Use the original paintable as a fail-safe + log.warn("preview scaling failed - falling back to original paintable", .{}); + src.setIcon( + paintable.as(gdk.Paintable), + @intFromFloat(mid_x), + @intFromFloat(mid_y), + ); + } + } + + fn surfaceDrop( + _: *gtk.DropTarget, + v: *const gobject.Value, + x: f64, + y: f64, + self: *Self, + ) callconv(.c) void { + const dropped_id = v.getUint64(); + const dropped = self.core().?.app.findSurfaceByID(dropped_id) orelse return; + const from = dropped.rt_surface.gobj(); + + const st = ext.getAncestor( + SplitTree, + self.as(gtk.Widget), + ) orelse { + log.warn("surface is not placed in a split tree", .{}); + return; + }; + + const dir = self.calcDropDirection(x, y); + + // The only error that could happen here is an OOM, + // and in that case we're already milliseconds away from crashing, so... + st.moveSplit(from, self, dir) catch return; + + // Clean up overlay state + self.setDropOverlayDirection(null); + } + + fn surfaceDropLeave( + _: *gtk.DropTarget, + self: *Self, + ) callconv(.c) void { + // Hide overlay + self.setDropOverlayDirection(null); + } + + fn surfaceDropMotion( + _: *gtk.DropTarget, + x: f64, + y: f64, + self: *Self, + ) callconv(.c) gdk.DragAction { + // Recalculate the drop region + const dir = self.calcDropDirection(x, y); + self.setDropOverlayDirection(dir); + return .{ .move = true }; + } + + fn propDropValue( + tgt: *gtk.DropTarget, + _: *gobject.ParamSpec, + self: *Self, + ) callconv(.c) void { + // Reject the drop if we're dropping a surface onto itself. + // Note that we cannot implement this via the `accept` signal, + // since the decision of whether to accept or deny a drop is dependent + // on the payload (i.e. the surface being dropped). This is + // well-documented in GTK docs. + + const core_surface = self.core() orelse return; + const value = tgt.getValue() orelse return; + const surface_id = value.getUint64(); + if (core_surface.id == surface_id) tgt.reject(); + } + + fn setDropOverlayDirection(self: *Self, dir: ?Tree.Split.Direction) void { + const priv = self.private(); + inline for (&.{ "drop-top", "drop-left", "drop-right", "drop-bottom" }) |c| { + priv.drop_overlay.removeCssClass(c); + } + + if (dir) |d| priv.drop_overlay.addCssClass(switch (d) { + .up => "drop-top", + .left => "drop-left", + .right => "drop-right", + .down => "drop-bottom", + }); + } + + fn calcDropDirection(self: *Self, x: f64, y: f64) Tree.Split.Direction { + const width: f64 = @floatFromInt(self.as(gtk.Widget).getWidth()); + const height: f64 = @floatFromInt(self.as(gtk.Widget).getHeight()); + + const l_dist = x / width; + const t_dist = y / height; + const r_dist = 1 - l_dist; + const b_dist = 1 - t_dist; + const min = @min(l_dist, t_dist, r_dist, b_dist); + + if (min == l_dist) return .left; + if (min == r_dist) return .right; + if (min == t_dist) return .up; + return .down; + } + const C = Common(Self, Private); pub const as = C.as; pub const ref = C.ref; @@ -3636,6 +3891,9 @@ pub const Surface = extern struct { class.bindTemplateChildPrivate("key_state_overlay", .{}); class.bindTemplateChildPrivate("terminal_page", .{}); class.bindTemplateChildPrivate("drop_target", .{}); + class.bindTemplateChildPrivate("surface_drop_target", .{}); + class.bindTemplateChildPrivate("drag_handle", .{}); + class.bindTemplateChildPrivate("drop_overlay", .{}); class.bindTemplateChildPrivate("im_context", .{}); // Template Callbacks @@ -3678,6 +3936,13 @@ pub const Surface = extern struct { class.bindTemplateCallback("search_changed", &searchChanged); class.bindTemplateCallback("search_next_match", &searchNextMatch); class.bindTemplateCallback("search_previous_match", &searchPreviousMatch); + class.bindTemplateCallback("should_drag_handle_be_shown", &closureShouldDragHandleBeShown); + class.bindTemplateCallback("surface_drag_prepare", &surfaceDragPrepare); + class.bindTemplateCallback("surface_drag_begin", &surfaceDragBegin); + class.bindTemplateCallback("surface_drop", &surfaceDrop); + class.bindTemplateCallback("surface_drop_leave", &surfaceDropLeave); + class.bindTemplateCallback("surface_drop_motion", &surfaceDropMotion); + class.bindTemplateCallback("notify_drop_value", &propDropValue); // Properties gobject.ext.registerProperties(class, &.{ @@ -3723,6 +3988,7 @@ pub const Surface = extern struct { // Virtual methods gobject.Object.virtual_methods.dispose.implement(class, &dispose); gobject.Object.virtual_methods.finalize.implement(class, &finalize); + gtk.Widget.virtual_methods.snapshot.implement(class, &snapshot); } pub const as = C.Class.as; @@ -3865,22 +4131,25 @@ const Clipboard = struct { ); } - /// Request data from the clipboard (read the clipboard). This - /// completes asynchronously and will call the `completeClipboardRequest` - /// core surface API when done. - /// - /// Returns true if the request was started, false if the clipboard - /// doesn't contain text (allowing performable keybinds to pass through). + /// Request data from the clipboard (read the clipboard). A started + /// request completes asynchronously and will call the + /// `completeClipboardRequest` core surface API when done. pub fn request( self: *Surface, clipboard_type: apprt.Clipboard, state: apprt.ClipboardRequest, - ) Allocator.Error!bool { + ) Allocator.Error!apprt.ClipboardReadResult { + // The GTK apprt doesn't support the Kitty clipboard protocol + // yet. + if (state == .kitty_read or + state == .kitty_write or + state == .list) return .unsupported; + // Get our requested clipboard const clipboard = get( self.private().gl_area.as(gtk.Widget), clipboard_type, - ) orelse return false; + ) orelse return .unsupported; // For paste requests, check if clipboard has text format available. // This is a synchronous check that allows performable keybinds to @@ -3889,7 +4158,7 @@ const Clipboard = struct { const formats = clipboard.getFormats(); if (formats.containGtype(gobject.ext.types.string) == 0) { log.debug("clipboard has no text format, not starting paste request", .{}); - return false; + return .unavailable; } } @@ -3912,7 +4181,7 @@ const Clipboard = struct { ud, ); - return true; + return .started; } /// Paste explicit text directly into the surface, regardless of the @@ -3925,16 +4194,15 @@ const Clipboard = struct { const surface = self.private().core_surface orelse return; surface.completeClipboardRequest( - .paste, - text, - false, + .{ .paste = .standard }, + .{ .contents = &.{.{ .mime = "text/plain", .data = text }} }, ) catch |err| switch (err) { error.UnsafePaste, error.UnauthorizedPaste, => { showClipboardConfirmation( self, - .paste, + .{ .paste = .standard }, text, ); return; @@ -3978,7 +4246,7 @@ const Clipboard = struct { .request = &req, .@"can-remember" = switch (req) { .osc_52_read, .osc_52_write => true, - .paste => false, + .paste, .list, .kitty_read, .kitty_write => false, }, .@"clipboard-contents" = contents_buf, }, @@ -4015,7 +4283,7 @@ const Clipboard = struct { if (remember) switch (req.*) { .osc_52_read => surface.config.clipboard_read = .allow, .osc_52_write => surface.config.clipboard_write = .allow, - .paste => {}, + .paste, .list, .kitty_read, .kitty_write => {}, }; // Get our text @@ -4032,11 +4300,10 @@ const Clipboard = struct { ?[:0]const u8, ) orelse return; - surface.completeClipboardRequest( - req.*, - text, - true, - ) catch |err| { + surface.completeClipboardRequest(req.*, .{ + .contents = &.{.{ .mime = "text/plain", .data = text }}, + .confirmed = true, + }) catch |err| { log.warn("failed to complete clipboard request: {}", .{err}); }; } @@ -4054,7 +4321,7 @@ const Clipboard = struct { if (remember) switch (req.*) { .osc_52_read => surface.config.clipboard_read = .deny, .osc_52_write => surface.config.clipboard_write = .deny, - .paste => @panic("paste should not be able to be remembered"), + .paste, .list, .kitty_read, .kitty_write => @panic("request should not be able to be remembered"), }; } @@ -4092,8 +4359,7 @@ const Clipboard = struct { const surface = self.private().core_surface orelse return; surface.completeClipboardRequest( req.state, - str, - false, + .{ .contents = &.{.{ .mime = "text/plain", .data = str }} }, ) catch |err| switch (err) { error.UnsafePaste, error.UnauthorizedPaste, @@ -4144,3 +4410,57 @@ test "computeFraction" { try std.testing.expectEqual(0.0, computeFraction(0)); try std.testing.expectEqual(0.5, computeFraction(50)); } + +/// Apply command and shell integration overrides received from the CLI. +/// Explicit commands should only receive shell integration when their +/// executable can be detected as a supported shell. An explicit shell +/// integration override is also valid without a command. +fn applyCommandOverrides( + config: *configpkg.Config, + command: ?configpkg.Command, + shell_integration: ?configpkg.Config.ShellIntegration, +) Allocator.Error!void { + if (command) |value| { + config.command = try value.clone(config.arenaAlloc()); + + if (shell_integration) |integration| { + config.@"shell-integration" = integration; + } else if (config.@"shell-integration" != .none) { + config.@"shell-integration" = .detect; + } + } else if (shell_integration) |value| { + config.@"shell-integration" = value; + } +} + +test "command and shell integration overrides" { + const testing = std.testing; + + var config = try configpkg.Config.default(testing.allocator); + defer config.deinit(); + + config.@"shell-integration" = .nushell; + try applyCommandOverrides(&config, .{ .shell = "vim" }, null); + try testing.expectEqual(.detect, config.@"shell-integration"); + + config.@"shell-integration" = .none; + try applyCommandOverrides(&config, .{ .shell = "vim" }, null); + try testing.expectEqual(.none, config.@"shell-integration"); + + try applyCommandOverrides(&config, .{ .shell = "nu" }, .nushell); + try testing.expectEqual(.nushell, config.@"shell-integration"); + + try applyCommandOverrides(&config, .{ .shell = "vim" }, .none); + try testing.expectEqual(.none, config.@"shell-integration"); + + config.@"shell-integration" = .nushell; + try applyCommandOverrides(&config, null, null); + try testing.expectEqual(.nushell, config.@"shell-integration"); + + config.@"shell-integration" = .none; + try applyCommandOverrides(&config, null, .nushell); + try testing.expectEqual(.nushell, config.@"shell-integration"); + + try applyCommandOverrides(&config, null, .none); + try testing.expectEqual(.none, config.@"shell-integration"); +} diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/surface_child_exited.zig b/vendor/libghostty-vt/src/apprt/gtk/class/surface_child_exited.zig index d7dd41bc..3c103d59 100644 --- a/vendor/libghostty-vt/src/apprt/gtk/class/surface_child_exited.zig +++ b/vendor/libghostty-vt/src/apprt/gtk/class/surface_child_exited.zig @@ -106,11 +106,11 @@ const SurfaceChildExitedBanner = extern struct { return; }; if (data.exit_code == 0) { - banner.as(adw.Banner).setTitle(i18n._("Command succeeded")); + banner.as(adw.Banner).setTitle(i18n._("Command Succeeded")); self.as(gtk.Widget).addCssClass("normal"); self.as(gtk.Widget).removeCssClass("abnormal"); } else { - banner.as(adw.Banner).setTitle(i18n._("Command failed")); + banner.as(adw.Banner).setTitle(i18n._("Command Failed")); self.as(gtk.Widget).removeCssClass("normal"); self.as(gtk.Widget).addCssClass("abnormal"); } diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/surface_scrolled_window.zig b/vendor/libghostty-vt/src/apprt/gtk/class/surface_scrolled_window.zig index 6ccb0a0d..ad9a4cc7 100644 --- a/vendor/libghostty-vt/src/apprt/gtk/class/surface_scrolled_window.zig +++ b/vendor/libghostty-vt/src/apprt/gtk/class/surface_scrolled_window.zig @@ -89,7 +89,8 @@ pub const SurfaceScrolledWindow = extern struct { const priv = self.private(); if (priv.config_binding) |binding| { - binding.as(gobject.Object).unref(); + binding.unbind(); + binding.unref(); priv.config_binding = null; } @@ -168,18 +169,23 @@ pub const SurfaceScrolledWindow = extern struct { // Unbind old config binding if it exists if (priv.config_binding) |binding| { - binding.as(gobject.Object).unref(); + binding.unbind(); + binding.unref(); priv.config_binding = null; } // Bind config from surface to our config property if (priv.surface) |surface| { - priv.config_binding = surface.as(gobject.Object).bindProperty( + const binding = surface.as(gobject.Object).bindProperty( properties.config.name, self.as(gobject.Object), properties.config.name, .{ .sync_create = true }, ); + // Keep another ref, otherwise the binding would be freed and + // our pointer become stale if the surface gets finalized. + binding.ref(); + priv.config_binding = binding; } } diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/tab.zig b/vendor/libghostty-vt/src/apprt/gtk/class/tab.zig index 0c60c8cc..204be58f 100644 --- a/vendor/libghostty-vt/src/apprt/gtk/class/tab.zig +++ b/vendor/libghostty-vt/src/apprt/gtk/class/tab.zig @@ -162,7 +162,7 @@ pub const Tab = extern struct { /// The title of this tab. This is usually bound to the active surface. title: ?[:0]const u8 = null, - /// The manually overridden title from `promptTabTitle`. + /// The manually overridden title. title_override: ?[:0]const u8 = null, /// The tooltip of this tab. This is usually bound to the active surface. @@ -189,6 +189,7 @@ pub const Tab = extern struct { pub fn new(config: ?*Config, overrides: struct { command: ?configpkg.Command = null, + shell_integration: ?configpkg.Config.ShellIntegration = null, working_directory: ?[:0]const u8 = null, title: ?[:0]const u8 = null, @@ -212,6 +213,7 @@ pub const Tab = extern struct { // Create our initial surface in the split tree. priv.split_tree.newSplit(.right, null, .{ .command = overrides.command, + .shell_integration = overrides.shell_integration, .working_directory = overrides.working_directory, .title = overrides.title, }) catch |err| switch (err) { diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/title_dialog.zig b/vendor/libghostty-vt/src/apprt/gtk/class/title_dialog.zig index ac95ae4b..057e9dee 100644 --- a/vendor/libghostty-vt/src/apprt/gtk/class/title_dialog.zig +++ b/vendor/libghostty-vt/src/apprt/gtk/class/title_dialog.zig @@ -220,10 +220,13 @@ pub const TitleDialog = extern struct { pub const Target = enum(c_int) { surface, tab, + window, + pub fn title(self: Target) [*:0]const u8 { return switch (self) { .surface => i18n._("Change Terminal Title"), .tab => i18n._("Change Tab Title"), + .window => i18n._("Change Window Title"), }; } diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/window.zig b/vendor/libghostty-vt/src/apprt/gtk/class/window.zig index c34f2cb4..e2fe6403 100644 --- a/vendor/libghostty-vt/src/apprt/gtk/class/window.zig +++ b/vendor/libghostty-vt/src/apprt/gtk/class/window.zig @@ -29,6 +29,7 @@ const Tab = @import("tab.zig").Tab; const DebugWarning = @import("debug_warning.zig").DebugWarning; const CommandPalette = @import("command_palette.zig").CommandPalette; const WeakRef = @import("../weak_ref.zig").WeakRef; +const TitleDialog = @import("title_dialog.zig").TitleDialog; const log = std.log.scoped(.gtk_ghostty_window); @@ -213,6 +214,19 @@ pub const Window = extern struct { }, ); }; + + pub const @"title-override" = struct { + pub const name = "title-override"; + const impl = gobject.ext.defineProperty( + name, + Self, + ?[:0]const u8, + .{ + .default = null, + .accessor = C.privateStringFieldAccessor("title_override"), + }, + ); + }; }; const Private = struct { @@ -259,6 +273,9 @@ pub const Window = extern struct { /// setup by `setup-menu`. context_menu_page: ?*adw.TabPage = null, + /// The manually overridden title. + title_override: ?[:0]const u8 = null, + // Template bindings tab_overview: *adw.TabOverview, tab_bar: *adw.TabBar, @@ -365,6 +382,7 @@ pub const Window = extern struct { .init("prompt-surface-title", actionPromptSurfaceTitle, null), .init("prompt-tab-title", actionPromptTabTitle, null), .init("prompt-context-tab-title", actionPromptContextTabTitle, null), + .init("prompt-window-title", actionPromptWindowTitle, null), .init("ring-bell", actionRingBell, null), .init("split-right", actionSplitRight, null), .init("split-left", actionSplitLeft, null), @@ -390,8 +408,20 @@ pub const Window = extern struct { /// Create a new tab with the given parent. The tab will be inserted /// at the position dictated by the `window-new-tab-position` config. /// The new tab will be selected. - pub fn newTab(self: *Self, parent_: ?*CoreSurface) void { - _ = self.newTabPage(parent_, .tab, .none); + pub fn newTab(self: *Self, parent_: ?*CoreSurface, overrides: struct { + command: ?configpkg.Command = null, + shell_integration: ?configpkg.Config.ShellIntegration = null, + working_directory: ?[:0]const u8 = null, + title: ?[:0]const u8 = null, + + pub const none: @This() = .{}; + }) void { + _ = self.newTabPage(parent_, .tab, .{ + .command = overrides.command, + .shell_integration = overrides.shell_integration, + .working_directory = overrides.working_directory, + .title = overrides.title, + }); } pub fn newTabForWindow( @@ -399,6 +429,7 @@ pub const Window = extern struct { parent_: ?*CoreSurface, overrides: struct { command: ?configpkg.Command = null, + shell_integration: ?configpkg.Config.ShellIntegration = null, working_directory: ?[:0]const u8 = null, title: ?[:0]const u8 = null, @@ -410,6 +441,7 @@ pub const Window = extern struct { .window, .{ .command = overrides.command, + .shell_integration = overrides.shell_integration, .working_directory = overrides.working_directory, .title = overrides.title, }, @@ -422,6 +454,7 @@ pub const Window = extern struct { context: apprt.surface.NewSurfaceContext, overrides: struct { command: ?configpkg.Command = null, + shell_integration: ?configpkg.Config.ShellIntegration = null, working_directory: ?[:0]const u8 = null, title: ?[:0]const u8 = null, @@ -436,6 +469,7 @@ pub const Window = extern struct { priv.config, .{ .command = overrides.command, + .shell_integration = overrides.shell_integration, .working_directory = overrides.working_directory, .title = overrides.title, }, @@ -604,6 +638,29 @@ pub const Window = extern struct { return tab_view.reorderPage(page, desired_pos) != 0; } + pub fn moveTabToNewWindow(self: *Self, surface: *Surface) bool { + const tab_view = self.private().tab_view; + const tab = ext.getAncestor( + Tab, + surface.as(gtk.Widget), + ) orelse return false; + const page = tab_view.getPage(tab.as(gtk.Widget)); + + // Skip if this is the only tab in the existing window + if (tab_view.getNPages() <= 1) return false; + + const app = Application.default(); + const window = Window.new(app, .none); + + tab_view.transferPage( + page, + window.private().tab_view, + 0, + ); + window.as(gtk.Window).present(); + return true; + } + pub fn toggleTabOverview(self: *Self) void { const priv = self.private(); const tab_overview = priv.tab_overview; @@ -1268,6 +1325,57 @@ pub const Window = extern struct { }); } + pub fn setTitleOverride(self: *Self, title: ?[]const u8) void { + const priv: *Private = self.private(); + + if (priv.title_override) |v| glib.free(@ptrCast(@constCast(v))); + priv.title_override = null; + + if (title) |v| priv.title_override = glib.ext.dupeZ(u8, v); + + self.as(gobject.Object).notifyByPspec(properties.@"title-override".impl.param_spec); + } + + fn titleDialogSet(_: *TitleDialog, title_: [*:0]const u8, self: *Self) callconv(.c) void { + const title = std.mem.span(title_); + self.setTitleOverride(if (title.len == 0) null else title); + } + + pub fn promptWindowTitle(self: *Self) void { + const priv: *Private = self.private(); + + var value = std.mem.zeroes(gobject.Value); + _ = value.init(gobject.ext.types.string); + defer value.unset(); + + self.as(gobject.Object).getProperty("title", &value); + + const title_: ?[*:0]const u8 = value.getString(); + const title: ?[:0]const u8 = if (title_) |v| std.mem.span(v) else null; + + const dialog = TitleDialog.new(.window, priv.title_override orelse title); + _ = TitleDialog.signals.set.connect( + dialog, + *Self, + titleDialogSet, + self, + .{}, + ); + + dialog.present(self.as(gtk.Widget)); + } + + fn closureTitle( + _: *Self, + _: ?*Config, + title_: ?[*:0]const u8, + title_override_: ?[*:0]const u8, + ) callconv(.c) ?[*:0]const u8 { + if (title_override_) |v| return glib.ext.dupeZ(u8, std.mem.span(v)); + if (title_) |v| return glib.ext.dupeZ(u8, std.mem.span(v)); + return glib.ext.dupeZ(u8, "Ghostty"); + } + fn closureSubtitle( _: *Self, config_: ?*Config, @@ -1296,7 +1404,7 @@ pub const Window = extern struct { priv.handle_active_state_source = null; } - priv.command_palette.set(null); + priv.command_palette.deinit(); if (priv.config) |v| { v.unref(); @@ -1320,6 +1428,7 @@ pub const Window = extern struct { const priv = self.private(); priv.tab_bindings.unref(); priv.winproto.deinit(); + if (priv.title_override) |v| glib.free(@ptrCast(@constCast(v))); gobject.Object.virtual_methods.finalize.call( Class.parent, @@ -1374,11 +1483,33 @@ pub const Window = extern struct { } } + // Notify every displayed surface when the compositor changes the + // Wayland xdg_toplevel suspended state. + _ = gobject.Object.signals.notify.connect( + self.as(gtk.Window), + *Self, + propSuspended, + self, + .{ .detail = "suspended" }, + ); + // When we are realized we always setup our appearance since this // calls some winproto functions. self.syncAppearance(); } + fn propSuspended( + _: *gtk.Window, + _: *gobject.ParamSpec, + self: *Self, + ) callconv(.c) void { + const tab = self.getSelectedTab() orelse return; + const tree = tab.getSurfaceTree() orelse return; + + var it = tree.iterator(); + while (it.next()) |entry| entry.view.updateOcclusion(); + } + fn btnNewTab(_: *adw.SplitButton, self: *Self) callconv(.c) void { self.performBindingAction(.new_tab); } @@ -1954,6 +2085,14 @@ pub const Window = extern struct { self.performBindingAction(.prompt_tab_title); } + fn actionPromptWindowTitle( + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Window, + ) callconv(.c) void { + self.performBindingAction(.prompt_window_title); + } + fn actionSplitRight( _: *gio.SimpleAction, _: ?*glib.Variant, @@ -2161,6 +2300,7 @@ pub const Window = extern struct { properties.@"tabs-wide".impl, properties.@"toolbar-style".impl, properties.@"titlebar-style".impl, + properties.@"title-override".impl, }); // Bindings @@ -2191,6 +2331,7 @@ pub const Window = extern struct { class.bindTemplateCallback("notify_quick_terminal", &propQuickTerminal); class.bindTemplateCallback("notify_scale_factor", &propScaleFactor); class.bindTemplateCallback("titlebar_style_is_tabs", &closureTitlebarStyleIsTab); + class.bindTemplateCallback("computed_title", &closureTitle); class.bindTemplateCallback("computed_subtitle", &closureSubtitle); // Virtual methods diff --git a/vendor/libghostty-vt/src/apprt/gtk/css/style.css b/vendor/libghostty-vt/src/apprt/gtk/css/style.css index 9c0f115f..045d9906 100644 --- a/vendor/libghostty-vt/src/apprt/gtk/css/style.css +++ b/vendor/libghostty-vt/src/apprt/gtk/css/style.css @@ -127,7 +127,7 @@ label.resize-overlay { } .surface .bell-overlay { - border-color: rgba(58, 148, 74, 0.5); + border-color: rgba(53, 132, 228, 0.5); /* after GTK 4.16 is a requirement, switch to the following: */ /* background-color: color-mix(in srgb, var(--accent-color), transparent 50%); */ border-width: 3px; @@ -144,6 +144,58 @@ label.resize-overlay { outline-style: solid; outline-width: 1px; } +/* + * Drag and drop overlay + */ +.drag-handle { + background-color: rgba(0, 0, 0, 0.15); + opacity: 0; + transition: 0.1s opacity; +} +.drag-handle:hover { + opacity: 1; +} +.drop-overlay.drop-left { + /* after GTK 4.16 is a requirement, switch to the following: */ + /* background: linear-gradient(to left, transparent, 50%, color-mix(in srgb, var(--accent-color), transparent 50%) 50%); */ + background: linear-gradient( + to left, + transparent, + 50%, + rgba(53, 132, 228, 0.2) 50% + ); +} +.drop-overlay.drop-right { + /* after GTK 4.16 is a requirement, switch to the following: */ + /* background: linear-gradient(to right, transparent, 50%, color-mix(in srgb, var(--accent-color), transparent 50%) 50%); */ + background: linear-gradient( + to right, + transparent, + 50%, + rgba(53, 132, 228, 0.2) 50% + ); +} +.drop-overlay.drop-top { + /* after GTK 4.16 is a requirement, switch to the following: */ + /* background: linear-gradient(to top, transparent, 50%, color-mix(in srgb, var(--accent-color), transparent 50%) 50%); */ + background: linear-gradient( + to top, + transparent, + 50%, + rgba(53, 132, 228, 0.2) 50% + ); +} +.drop-overlay.drop-bottom { + /* after GTK 4.16 is a requirement, switch to the following: */ + /* background: linear-gradient(to bottom, transparent, 50%, color-mix(in srgb, var(--accent-color), transparent 50%) 50%); */ + background: linear-gradient( + to bottom, + transparent, + 50%, + rgba(53, 132, 228, 0.2) 50% + ); +} + /* * Command Palette */ diff --git a/vendor/libghostty-vt/src/apprt/gtk/ext/actions.zig b/vendor/libghostty-vt/src/apprt/gtk/ext/actions.zig index 3232bc18..6ba9c678 100644 --- a/vendor/libghostty-vt/src/apprt/gtk/ext/actions.zig +++ b/vendor/libghostty-vt/src/apprt/gtk/ext/actions.zig @@ -176,7 +176,11 @@ test "adding actions to an object" { _ = addAsGroup(gtk.Box, box, "test", &actions); } - const expected = std.crypto.random.intRangeAtMost(i32, 1, std.math.maxInt(u31)); + const expected = expected: { + const rng_impl: std.Random.IoSource = .{ .io = testing.io }; + const rng = rng_impl.interface(); + break :expected rng.intRangeAtMost(i32, 1, std.math.maxInt(u31)); + }; const parameter = glib.Variant.newInt32(expected); try testing.expect(box.as(gtk.Widget).activateActionVariant("test.test", parameter) != 0); diff --git a/vendor/libghostty-vt/src/apprt/gtk/gtk_version.zig b/vendor/libghostty-vt/src/apprt/gtk/gtk_version.zig index 71edb076..97e0a523 100644 --- a/vendor/libghostty-vt/src/apprt/gtk/gtk_version.zig +++ b/vendor/libghostty-vt/src/apprt/gtk/gtk_version.zig @@ -3,18 +3,16 @@ const std = @import("std"); // Until the gobject bindings are built at the same time we are building // Ghostty, we need to import `gtk/gtk.h` directly to ensure that the version // macros match the version of `gtk4` that we are building/linking against. -const c = @cImport({ - @cInclude("gtk/gtk.h"); -}); +const gtk_c = @import("gtk_c"); const gtk = @import("gtk"); const log = std.log.scoped(.gtk); pub const comptime_version: std.SemanticVersion = .{ - .major = c.GTK_MAJOR_VERSION, - .minor = c.GTK_MINOR_VERSION, - .patch = c.GTK_MICRO_VERSION, + .major = gtk_c.GTK_MAJOR_VERSION, + .minor = gtk_c.GTK_MINOR_VERSION, + .patch = gtk_c.GTK_MICRO_VERSION, }; pub fn getRuntimeVersion() std.SemanticVersion { @@ -105,17 +103,17 @@ test "atLeast" { const funs = &.{ atLeast, runtimeAtLeast }; inline for (funs) |fun| { - try testing.expect(fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION)); + try testing.expect(fun(gtk_c.GTK_MAJOR_VERSION, gtk_c.GTK_MINOR_VERSION, gtk_c.GTK_MICRO_VERSION)); - try testing.expect(!fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION + 1)); - try testing.expect(!fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION + 1, c.GTK_MICRO_VERSION)); - try testing.expect(!fun(c.GTK_MAJOR_VERSION + 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION)); + try testing.expect(!fun(gtk_c.GTK_MAJOR_VERSION, gtk_c.GTK_MINOR_VERSION, gtk_c.GTK_MICRO_VERSION + 1)); + try testing.expect(!fun(gtk_c.GTK_MAJOR_VERSION, gtk_c.GTK_MINOR_VERSION + 1, gtk_c.GTK_MICRO_VERSION)); + try testing.expect(!fun(gtk_c.GTK_MAJOR_VERSION + 1, gtk_c.GTK_MINOR_VERSION, gtk_c.GTK_MICRO_VERSION)); - try testing.expect(fun(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION)); - try testing.expect(fun(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION + 1, c.GTK_MICRO_VERSION)); - try testing.expect(fun(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION + 1)); + try testing.expect(fun(gtk_c.GTK_MAJOR_VERSION - 1, gtk_c.GTK_MINOR_VERSION, gtk_c.GTK_MICRO_VERSION)); + try testing.expect(fun(gtk_c.GTK_MAJOR_VERSION - 1, gtk_c.GTK_MINOR_VERSION + 1, gtk_c.GTK_MICRO_VERSION)); + try testing.expect(fun(gtk_c.GTK_MAJOR_VERSION - 1, gtk_c.GTK_MINOR_VERSION, gtk_c.GTK_MICRO_VERSION + 1)); - try testing.expect(fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION - 1, c.GTK_MICRO_VERSION + 1)); + try testing.expect(fun(gtk_c.GTK_MAJOR_VERSION, gtk_c.GTK_MINOR_VERSION - 1, gtk_c.GTK_MICRO_VERSION + 1)); } } @@ -125,16 +123,16 @@ test "runtimeUntil" { // This is an array in case we add a comptime variant. const funs = &.{runtimeUntil}; inline for (funs) |fun| { - try testing.expect(!fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION)); + try testing.expect(!fun(gtk_c.GTK_MAJOR_VERSION, gtk_c.GTK_MINOR_VERSION, gtk_c.GTK_MICRO_VERSION)); - try testing.expect(fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION + 1)); - try testing.expect(fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION + 1, c.GTK_MICRO_VERSION)); - try testing.expect(fun(c.GTK_MAJOR_VERSION + 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION)); + try testing.expect(fun(gtk_c.GTK_MAJOR_VERSION, gtk_c.GTK_MINOR_VERSION, gtk_c.GTK_MICRO_VERSION + 1)); + try testing.expect(fun(gtk_c.GTK_MAJOR_VERSION, gtk_c.GTK_MINOR_VERSION + 1, gtk_c.GTK_MICRO_VERSION)); + try testing.expect(fun(gtk_c.GTK_MAJOR_VERSION + 1, gtk_c.GTK_MINOR_VERSION, gtk_c.GTK_MICRO_VERSION)); - try testing.expect(!fun(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION)); - try testing.expect(!fun(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION + 1, c.GTK_MICRO_VERSION)); - try testing.expect(!fun(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION + 1)); + try testing.expect(!fun(gtk_c.GTK_MAJOR_VERSION - 1, gtk_c.GTK_MINOR_VERSION, gtk_c.GTK_MICRO_VERSION)); + try testing.expect(!fun(gtk_c.GTK_MAJOR_VERSION - 1, gtk_c.GTK_MINOR_VERSION + 1, gtk_c.GTK_MICRO_VERSION)); + try testing.expect(!fun(gtk_c.GTK_MAJOR_VERSION - 1, gtk_c.GTK_MINOR_VERSION, gtk_c.GTK_MICRO_VERSION + 1)); - try testing.expect(!fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION - 1, c.GTK_MICRO_VERSION + 1)); + try testing.expect(!fun(gtk_c.GTK_MAJOR_VERSION, gtk_c.GTK_MINOR_VERSION - 1, gtk_c.GTK_MICRO_VERSION + 1)); } } diff --git a/vendor/libghostty-vt/src/apprt/gtk/ipc/DBus.zig b/vendor/libghostty-vt/src/apprt/gtk/ipc/DBus.zig index fa4a6723..66904783 100644 --- a/vendor/libghostty-vt/src/apprt/gtk/ipc/DBus.zig +++ b/vendor/libghostty-vt/src/apprt/gtk/ipc/DBus.zig @@ -6,6 +6,7 @@ const Allocator = std.mem.Allocator; const gio = @import("gio"); const glib = @import("glib"); +const global = @import("../../../global.zig"); const apprt = @import("../../../apprt.zig"); const ApprtApp = @import("../App.zig"); @@ -28,10 +29,12 @@ payload_builder: *glib.VariantBuilder, /// Used to build the parameters for the IPC. parameters_builder: *glib.VariantBuilder, +pub const InitError = Allocator.Error || std.Io.Writer.Error || apprt.ipc.Errors; + /// Initialize the helper. -pub fn init(alloc: Allocator, target: apprt.ipc.Target, action: [:0]const u8) (Allocator.Error || std.Io.Writer.Error || apprt.ipc.Errors)!Self { +pub fn init(alloc: Allocator, target: apprt.ipc.Target, action: [:0]const u8) InitError!Self { var buf: [256]u8 = undefined; - var stderr_writer = std.fs.File.stderr().writer(&buf); + var stderr_writer = std.Io.File.stderr().writer(global.io(), &buf); const stderr = &stderr_writer.interface; // Get the appropriate bus name and object path for contacting the @@ -133,7 +136,7 @@ pub fn addParameter(self: *Self, variant: *glib.Variant) void { /// should be done with this object other than call `deinit`. pub fn send(self: *Self) (std.Io.Writer.Error || apprt.ipc.Errors)!void { var buf: [256]u8 = undefined; - var stderr_writer = std.fs.File.stderr().writer(&buf); + var stderr_writer = std.Io.File.stderr().writer(global.io(), &buf); const stderr = &stderr_writer.interface; // finish building the parameters diff --git a/vendor/libghostty-vt/src/apprt/gtk/ipc/new_tab.zig b/vendor/libghostty-vt/src/apprt/gtk/ipc/new_tab.zig new file mode 100644 index 00000000..c5e53895 --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/ipc/new_tab.zig @@ -0,0 +1,71 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const glib = @import("glib"); + +const apprt = @import("../../../apprt.zig"); +const DBus = @import("DBus.zig"); + +// Use a D-Bus method call to open a new tab on GTK. +// See: https://wiki.gnome.org/Projects/GLib/GApplication/DBusAPI +// +// `ghostty +new-tab` is equivalent to the following command (on a release build): +// +// ``` +// gdbus call --session --dest com.mitchellh.ghostty --object-path /com/mitchellh/ghostty --method org.gtk.Actions.Activate new-tab '[<@(tas) (0, [])>]' [] +// ``` +// +// `ghostty +new-tab -e echo hello` would be equivalent to the following command (on a release build): +// +// ``` +// gdbus call --session --dest com.mitchellh.ghostty --object-path /com/mitchellh/ghostty --method org.gtk.Actions.Activate new-tab '[<@(tas) (0, ["-e" "echo" "hello"])>]' [] +// ``` +pub fn newTab(alloc: Allocator, target: apprt.ipc.Target, value: apprt.ipc.Action.NewTab) (Allocator.Error || std.Io.Writer.Error || apprt.ipc.Errors)!bool { + var dbus = try DBus.init(alloc, target, "new-tab"); + defer dbus.deinit(alloc); + + const tas_variant_type = glib.VariantType.new("(tas)"); + defer tas_variant_type.free(); + + var parameter: glib.VariantBuilder = undefined; + parameter.init(tas_variant_type); + errdefer parameter.clear(); + + { + // Add the target surface ID to the parameter. + const t = glib.Variant.newUint64(value.surface_id); + parameter.addValue(t); + } + + { + // If any arguments were specified on the command line, this value is an + // array of strings that contain the arguments. They will be sent to the + // main Ghostty instance and interpreted as CLI arguments. + const as_variant_type = glib.VariantType.new("as"); + defer as_variant_type.free(); + + const s_variant_type = glib.VariantType.new("s"); + defer s_variant_type.free(); + + var command: glib.VariantBuilder = undefined; + command.init(as_variant_type); + errdefer command.clear(); + + if (value.arguments) |arguments| { + for (arguments) |argument| { + const bytes = glib.Bytes.new(argument.ptr, argument.len + 1); + defer bytes.unref(); + const string = glib.Variant.newFromBytes(s_variant_type, bytes, @intFromBool(true)); + command.addValue(string); + } + } + + parameter.addValue(command.end()); + } + + dbus.addParameter(parameter.end()); + + try dbus.send(); + + return true; +} diff --git a/vendor/libghostty-vt/src/apprt/gtk/key.zig b/vendor/libghostty-vt/src/apprt/gtk/key.zig index 5f717e14..ff2064d4 100644 --- a/vendor/libghostty-vt/src/apprt/gtk/key.zig +++ b/vendor/libghostty-vt/src/apprt/gtk/key.zig @@ -57,6 +57,16 @@ pub fn xdgShortcutFromTrigger( return slice[0 .. slice.len - 1 :0]; } +/// Returns the keysym (identical to a GDK keyval) for a trigger key, +/// or null if the trigger has no keysym representation. +pub fn keysymFromTrigger(trigger: input.Binding.Trigger) ?c_uint { + return switch (trigger.key) { + .physical => |k| keyvalFromKey(k), + .unicode => |cp| gdk.unicodeToKeyval(cp), + .catch_all => null, + }; +} + fn writeTriggerKey( writer: *std.Io.Writer, trigger: input.Binding.Trigger, @@ -217,6 +227,94 @@ pub fn keyFromKeyval(keyval: c_uint) ?input.Key { return null; } +/// Returns the logical key after applying any eligible XKB remapping. +/// +/// Modifier keyvals that Ghostty doesn't represent must not fall back to the +/// physical key. XKB can turn any physical key into a modifier, and using the +/// original key in that case would encode an unrelated key press. +pub fn remapKey( + physical_key: input.Key, + keyval: c_uint, + is_modifier: bool, +) input.Key { + if (keyFromKeyval(keyval)) |remapped| { + if (physical_key.shouldBeRemappable() or remapped.shouldBeRemappable()) + return remapped; + } + + if (is_modifier or isModifierKeyval(keyval)) return .unidentified; + return physical_key; +} + +/// Returns whether a keyval has modifier semantics that GDK may not expose +/// through `gdk_key_event_is_modifier`. +/// +/// In particular, XKB can report ISO level and group modifiers as keyvals +/// such as `Mode_switch` while GDK reports that the event is not a modifier. +fn isModifierKeyval(keyval: c_uint) bool { + return switch (keyval) { + gdk.KEY_Shift_L, + gdk.KEY_Shift_R, + gdk.KEY_Shift_Lock, + gdk.KEY_Control_L, + gdk.KEY_Control_R, + gdk.KEY_Caps_Lock, + gdk.KEY_Meta_L, + gdk.KEY_Meta_R, + gdk.KEY_Alt_L, + gdk.KEY_Alt_R, + gdk.KEY_Super_L, + gdk.KEY_Super_R, + gdk.KEY_Hyper_L, + gdk.KEY_Hyper_R, + gdk.KEY_Num_Lock, + gdk.KEY_Mode_switch, + gdk.KEY_ISO_Lock, + gdk.KEY_ISO_Level2_Latch, + gdk.KEY_ISO_Level3_Shift, + gdk.KEY_ISO_Level3_Latch, + gdk.KEY_ISO_Level3_Lock, + gdk.KEY_ISO_Level5_Shift, + gdk.KEY_ISO_Level5_Latch, + gdk.KEY_ISO_Level5_Lock, + gdk.KEY_ISO_Group_Latch, + gdk.KEY_ISO_Group_Lock, + => true, + + else => false, + }; +} + +test "remap key" { + const testing = std.testing; + + // XKB remaps between non-writing-system keys are honored. + try testing.expectEqual( + input.Key.escape, + remapKey(.caps_lock, gdk.KEY_Escape, false), + ); + + // Writing-system keys retain their physical identity. + try testing.expectEqual( + input.Key.key_a, + remapKey(.key_a, gdk.KEY_b, false), + ); + + // GDK doesn't identify Mode_switch as a modifier, but it must not fall + // back to the physical key. + try testing.expectEqual( + input.Key.unidentified, + remapKey(.delete, gdk.KEY_Mode_switch, false), + ); + + // GDK's modifier flag also covers custom modifier actions whose keyval + // Ghostty doesn't represent. + try testing.expectEqual( + input.Key.unidentified, + remapKey(.delete, gdk.KEY_VoidSymbol, true), + ); +} + /// Returns a keyval from an input key or null if we don't have a mapping. pub fn keyvalFromKey(key: input.Key) ?c_uint { switch (key) { @@ -327,6 +425,28 @@ test "xdgShortcutFromTrigger" { })).?); } +test "keysymFromTrigger" { + const testing = std.testing; + + // Unicode keys use the keysym numbering (latin-1 maps directly). + try testing.expectEqual(@as(?c_uint, 'q'), keysymFromTrigger(.{ + .mods = .{ .super = true }, + .key = .{ .unicode = 'q' }, + })); + + // Physical keys map through our keymap. + try testing.expectEqual(@as(?c_uint, gdk.KEY_a), keysymFromTrigger(.{ + .mods = .{}, + .key = .{ .physical = .key_a }, + })); + + // catch_all has no keysym representation. + try testing.expectEqual(@as(?c_uint, null), keysymFromTrigger(.{ + .mods = .{}, + .key = .catch_all, + })); +} + test "labelFromTrigger" { const testing = std.testing; diff --git a/vendor/libghostty-vt/src/apprt/gtk/media.zig b/vendor/libghostty-vt/src/apprt/gtk/media.zig index 7883bf37..3e6a19c3 100644 --- a/vendor/libghostty-vt/src/apprt/gtk/media.zig +++ b/vendor/libghostty-vt/src/apprt/gtk/media.zig @@ -7,10 +7,15 @@ const gio = @import("gio"); const glib = @import("glib"); const gobject = @import("gobject"); const gtk = @import("gtk"); +const global = @import("../../global.zig"); pub fn fromFilename(path: [:0]const u8) ?*gtk.MediaFile { assert(std.fs.path.isAbsolute(path)); - std.fs.accessAbsolute(path, .{ .mode = .read_only }) catch |err| { + std.Io.Dir.accessAbsolute( + global.io(), + path, + .{ .read = true }, + ) catch |err| { log.warn("unable to access {s}: {t}", .{ path, err }); return null; }; diff --git a/vendor/libghostty-vt/src/apprt/gtk/portal.zig b/vendor/libghostty-vt/src/apprt/gtk/portal.zig index 3e51042d..11c3356d 100644 --- a/vendor/libghostty-vt/src/apprt/gtk/portal.zig +++ b/vendor/libghostty-vt/src/apprt/gtk/portal.zig @@ -1,6 +1,7 @@ const std = @import("std"); const gio = @import("gio"); +const global = @import("../../global.zig"); const Allocator = std.mem.Allocator; @@ -11,7 +12,8 @@ const token_format = std.fmt.comptimePrint("{{x:0>{}}}", .{token_hex_len}); /// Generate a token suitable for use in requests to the XDG Desktop Portal pub fn generateToken() usize { - return std.crypto.random.int(usize); + const rng_impl: std.Random.IoSource = .{ .io = global.io() }; + return rng_impl.interface().int(usize); } /// Format a request token consistently for use in portal object paths and payloads. diff --git a/vendor/libghostty-vt/src/apprt/gtk/portal/OpenURI.zig b/vendor/libghostty-vt/src/apprt/gtk/portal/OpenURI.zig index 97aa013e..40191482 100644 --- a/vendor/libghostty-vt/src/apprt/gtk/portal/OpenURI.zig +++ b/vendor/libghostty-vt/src/apprt/gtk/portal/OpenURI.zig @@ -9,6 +9,7 @@ const assert = std.debug.assert; const gio = @import("gio"); const glib = @import("glib"); const gobject = @import("gobject"); +const global = @import("../../../global.zig"); const App = @import("../App.zig"); const portal = @import("../portal.zig"); @@ -23,7 +24,7 @@ app: *App, dbus: ?*gio.DBusConnection = null, /// Mutex to protect modification of the entries map or the cleanup timer. -mutex: std.Thread.Mutex = .{}, +mutex: std.Io.Mutex = .init, /// Map to store data about any in-flight calls to the portal. entries: std.AutoArrayHashMapUnmanaged(usize, *Entry) = .empty, @@ -78,7 +79,7 @@ const RequestData = struct { /// Data about any in-flight calls to the portal. pub const Entry = struct { /// When the request started. - start: std.time.Instant, + start: std.Io.Timestamp, /// A token used by the portal to identify requests and responses. The /// actual format of the token does not really matter as long as it can be /// used as part of a D-Bus object path. `usize` was chosen since it's easy @@ -123,8 +124,8 @@ pub fn setDbusConnection(self: *OpenURI, dbus: ?*gio.DBusConnection) void { pub fn deinit(self: *OpenURI) void { const alloc = self.app.app.allocator(); - self.mutex.lock(); - defer self.mutex.unlock(); + self.mutex.lockUncancelable(global.io()); + defer self.mutex.unlock(global.io()); if (!self.alive) return; self.alive = false; @@ -157,8 +158,8 @@ pub fn start(self: *OpenURI, value: apprt.action.OpenUrl) (Allocator.Error || Er alloc.destroy(request); } - self.mutex.lock(); - defer self.mutex.unlock(); + self.mutex.lockUncancelable(global.io()); + defer self.mutex.unlock(global.io()); // Create an entry that is used to track the results of the D-Bus method // call. @@ -166,7 +167,7 @@ pub fn start(self: *OpenURI, value: apprt.action.OpenUrl) (Allocator.Error || Er const entry = try alloc.create(Entry); errdefer alloc.destroy(entry); entry.* = .{ - .start = std.time.Instant.now() catch return error.TimerUnavailable, + .start = std.Io.Timestamp.now(global.io(), .awake), .token = token, .kind = value.kind, .uri = try alloc.dupeZ(u8, value.url), @@ -235,8 +236,8 @@ fn destroyEntry(alloc: Allocator, entry: *Entry) void { } fn failRequest(self: *OpenURI, token: usize) ?*Entry { - self.mutex.lock(); - defer self.mutex.unlock(); + self.mutex.lockUncancelable(global.io()); + defer self.mutex.unlock(global.io()); if (!self.alive) return null; @@ -422,8 +423,8 @@ fn requestCallback( return; } - self.mutex.lock(); - defer self.mutex.unlock(); + self.mutex.lockUncancelable(global.io()); + defer self.mutex.unlock(global.io()); if (!self.alive) return; @@ -461,8 +462,8 @@ fn responseReceived( return; }; - self.mutex.lock(); - defer self.mutex.unlock(); + self.mutex.lockUncancelable(global.io()); + defer self.mutex.unlock(global.io()); if (!self.alive) return; @@ -534,22 +535,18 @@ fn cleanup(ud: ?*anyopaque) callconv(.c) c_int { const alloc = self.app.app.allocator(); - self.mutex.lock(); - defer self.mutex.unlock(); + self.mutex.lockUncancelable(global.io()); + defer self.mutex.unlock(global.io()); self.cleanup_timer = null; if (!self.alive) return @intFromBool(glib.SOURCE_REMOVE); - const now = std.time.Instant.now() catch { - // `now()` should never fail, but if it does, don't crash, just return. - // This might cause a small memory leak in rare circumstances but it - // should get cleaned up the next time a URL is clicked. - return @intFromBool(glib.SOURCE_REMOVE); - }; - loop: while (true) { for (self.entries.entries.items(.value)) |entry| { - if (now.since(entry.start) > cleanup_timeout * std.time.ns_per_s) { + if (entry.start.untilNow( + global.io(), + .awake, + ).toSeconds() > cleanup_timeout) { log.warn("open uri request timed out token={x}", .{entry.token}); self.unsubscribeFromResponse(entry); _ = self.entries.swapRemove(entry.token); diff --git a/vendor/libghostty-vt/src/apprt/gtk/post_fork.zig b/vendor/libghostty-vt/src/apprt/gtk/post_fork.zig index ff021950..d9b576fa 100644 --- a/vendor/libghostty-vt/src/apprt/gtk/post_fork.zig +++ b/vendor/libghostty-vt/src/apprt/gtk/post_fork.zig @@ -2,6 +2,7 @@ const std = @import("std"); const gio = @import("gio"); const glib = @import("glib"); +const global = @import("../../global.zig"); const log = std.log.scoped(.gtk_post_fork); @@ -85,12 +86,10 @@ pub fn postFork(cmd: *Command) Command.PostForkError!void { return; }; - const start = std.time.Instant.now() catch unreachable; + const start: std.Io.Timestamp = .now(global.io(), .awake); loop: while (true) { - const now = std.time.Instant.now() catch unreachable; - - if (now.since(start) > 250 * std.time.ns_per_ms) { + if (start.untilNow(global.io(), .awake).toMilliseconds() > 250) { if (cmd.rt_pre_exec_info.linux_cgroup_hard_fail) { log.err("transition to new transient systemd scope {s} took too long", .{expected_cgroup}); return error.PostForkError; @@ -116,6 +115,6 @@ pub fn postFork(cmd: *Command) Command.PostForkError!void { } } - std.Thread.sleep(25 * std.time.ns_per_ms); + std.Io.sleep(global.io(), .fromMilliseconds(25), .awake) catch unreachable; } } diff --git a/vendor/libghostty-vt/src/apprt/gtk/pre_exec.zig b/vendor/libghostty-vt/src/apprt/gtk/pre_exec.zig index 6f6a9ed5..a4eda462 100644 --- a/vendor/libghostty-vt/src/apprt/gtk/pre_exec.zig +++ b/vendor/libghostty-vt/src/apprt/gtk/pre_exec.zig @@ -3,6 +3,7 @@ const std = @import("std"); const log = std.log.scoped(.gtk_pre_exec); const configpkg = @import("../../config.zig"); +const global = @import("../../global.zig"); const internal_os = @import("../../os/main.zig"); const Command = @import("../../Command.zig"); @@ -47,12 +48,10 @@ pub fn preExec(cmd: *Command) ?u8 { var expected_cgroup_buf: [256]u8 = undefined; const expected_cgroup = cgroup.fmtScope(&expected_cgroup_buf, pid); - const start = std.time.Instant.now() catch unreachable; + const start: std.Io.Timestamp = .now(global.io(), .awake); while (true) { - const now = std.time.Instant.now() catch unreachable; - - if (now.since(start) > 250 * std.time.ns_per_ms) { + if (start.untilNow(global.io(), .awake).toMilliseconds() > 250) { if (cmd.rt_pre_exec_info.linux_cgroup_hard_fail) { log.err("transition to new transient systemd scope took too long", .{}); return 127; @@ -74,7 +73,7 @@ pub fn preExec(cmd: *Command) ?u8 { if (std.mem.eql(u8, current_cgroup, expected_cgroup)) return null; } - std.Thread.sleep(25 * std.time.ns_per_ms); + std.Io.sleep(global.io(), .fromMilliseconds(25), .awake) catch unreachable; } return null; diff --git a/vendor/libghostty-vt/src/apprt/gtk/ui/1.2/config-errors-dialog.blp b/vendor/libghostty-vt/src/apprt/gtk/ui/1.2/config-errors-dialog.blp index 845909eb..77c71ede 100644 --- a/vendor/libghostty-vt/src/apprt/gtk/ui/1.2/config-errors-dialog.blp +++ b/vendor/libghostty-vt/src/apprt/gtk/ui/1.2/config-errors-dialog.blp @@ -4,7 +4,9 @@ using Adw 1; template $GhosttyConfigErrorsDialog: $GhosttyDialog { heading: _("Configuration Errors"); - body: _("One or more configuration errors were found. Please review the errors below, and either reload your configuration or ignore these errors."); + body: _( + "One or more configuration errors were found. Please review the errors below, and either reload your configuration or ignore these errors." + ); responses [ ignore: _("Ignore"), diff --git a/vendor/libghostty-vt/src/apprt/gtk/ui/1.2/search-overlay.blp b/vendor/libghostty-vt/src/apprt/gtk/ui/1.2/search-overlay.blp index 6523d414..8ab2e310 100644 --- a/vendor/libghostty-vt/src/apprt/gtk/ui/1.2/search-overlay.blp +++ b/vendor/libghostty-vt/src/apprt/gtk/ui/1.2/search-overlay.blp @@ -46,7 +46,12 @@ template $GhosttySearchOverlay: Adw.Bin { "dim-label", ] - label: bind $match_label_closure(template.has-search-selected, template.search-selected, template.has-search-total, template.search-total) as ; + label: bind $match_label_closure( + template.has-search-selected, + template.search-selected, + template.has-search-total, + template.search-total + ) as ; width-chars: 6; xalign: 1.0; } diff --git a/vendor/libghostty-vt/src/apprt/gtk/ui/1.2/surface.blp b/vendor/libghostty-vt/src/apprt/gtk/ui/1.2/surface.blp index 2ae0a347..010798ae 100644 --- a/vendor/libghostty-vt/src/apprt/gtk/ui/1.2/surface.blp +++ b/vendor/libghostty-vt/src/apprt/gtk/ui/1.2/surface.blp @@ -1,4 +1,5 @@ using Gtk 4.0; +using Gdk 4.0; using Adw 1; Adw.StatusPage error_page { @@ -96,7 +97,9 @@ Overlay terminal_page { // TODO: the tooltip doesn't actually work, but keep it here for now so // that we can get the tooltip text translated. has-tooltip: true; - tooltip-text: _("This terminal is in read-only mode. You can still view, select, and scroll through the content, but no input events will be sent to the running application."); + tooltip-text: _( + "This terminal is in read-only mode. You can still view, select, and scroll through the content, but no input events will be sent to the running application." + ); halign: end; valign: start; spacing: 6; @@ -205,7 +208,11 @@ Overlay terminal_page { // Apply unfocused-split-fill and unfocused-split-opacity to current surface // this is only applied when a tab has more than one surface Revealer { - reveal-child: bind $should_unfocused_split_be_shown(search_overlay.active, template.focused, template.is-split) as ; + reveal-child: bind $should_unfocused_split_be_shown( + search_overlay.active, + template.focused, + template.is-split + ) as ; transition-duration: 0; // This is all necessary so that the Revealer itself doesn't override // any input events from the other overlays. Namely, if you don't have @@ -221,10 +228,57 @@ Overlay terminal_page { } } + [overlay] + CenterBox drag_handle { + halign: fill; + valign: start; + visible: bind $should_drag_handle_be_shown(template.config, template.is-split) as ; + + styles [ + "drag-handle", + ] + + [center] + Image { + icon-name: "view-more-horizontal-symbolic"; + } + + DragSource surface_drag_source { + actions: move; + prepare => $surface_drag_prepare(); + drag-begin => $surface_drag_begin(); + } + } + + [overlay] + DrawingArea drop_overlay { + halign: fill; + valign: fill; + can-focus: false; + can-target: false; + focusable: false; + + styles [ + "drop-overlay", + ] + } + DropTarget drop_target { drop => $drop(); actions: copy | move; } + + DropTarget surface_drop_target { + // Enabling preload is required since that lets us know + // which surface is currently being dragged, and to turn off + // the drop overlay if the source and target are the same + preload: true; + leave => $surface_drop_leave(); + motion => $surface_drop_motion(); + drop => $surface_drop(); + notify::value => $notify_drop_value(); + actions: move; + } } template $GhosttySurface: Adw.Bin { @@ -348,6 +402,11 @@ menu context_menu_model { submenu { label: _("Window"); + item { + label: _("Change Window Title…"); + action: "win.prompt-window-title"; + } + item { label: _("New Window"); action: "win.new-window"; @@ -365,8 +424,15 @@ menu context_menu_model { label: _("Config"); item { - label: _("Open Configuration"); + label: _("Open Configuration in OS Editor"); + action: "app.open-config"; + target: "os-open"; + } + + item { + label: _("Open Configuration in New Window"); action: "app.open-config"; + target: "new-window"; } item { diff --git a/vendor/libghostty-vt/src/apprt/gtk/ui/1.5/tab.blp b/vendor/libghostty-vt/src/apprt/gtk/ui/1.5/tab.blp index 55f2e7ef..21a30bec 100644 --- a/vendor/libghostty-vt/src/apprt/gtk/ui/1.5/tab.blp +++ b/vendor/libghostty-vt/src/apprt/gtk/ui/1.5/tab.blp @@ -8,7 +8,14 @@ template $GhosttyTab: Box { orientation: vertical; hexpand: true; vexpand: true; - title: bind $computed_title(template.config, split_tree.active-surface as <$GhosttySurface>.title, split_tree.active-surface as <$GhosttySurface>.title-override, template.title-override, split_tree.is-zoomed, split_tree.active-surface as <$GhosttySurface>.bell-ringing) as ; + title: bind $computed_title( + template.config, + split_tree.active-surface as <$GhosttySurface>.title, + split_tree.active-surface as <$GhosttySurface>.title-override, + template.title-override, + split_tree.is-zoomed, + split_tree.active-surface as <$GhosttySurface>.bell-ringing + ) as ; tooltip: bind split_tree.active-surface as <$GhosttySurface>.pwd; $GhosttySplitTree split_tree { diff --git a/vendor/libghostty-vt/src/apprt/gtk/ui/1.5/window.blp b/vendor/libghostty-vt/src/apprt/gtk/ui/1.5/window.blp index b66a9309..32d1bf37 100644 --- a/vendor/libghostty-vt/src/apprt/gtk/ui/1.5/window.blp +++ b/vendor/libghostty-vt/src/apprt/gtk/ui/1.5/window.blp @@ -40,14 +40,17 @@ template $GhosttyWindow: Adw.ApplicationWindow { visible: bind template.headerbar-visible; title-widget: Adw.WindowTitle { - title: bind template.title; + title: bind $computed_title(template.config, template.title, template.title-override) as ; // Blueprint auto-formatter won't let me split this into multiple // lines. Let me explain myself. All parameters to a closure are used // as notifications to recompute the value of the closure. All // elements of a property chain are also subscribed to for changes. // This one long, ugly line saves us from manually building up this // massive notify chain in code. - subtitle: bind $computed_subtitle(template.config, tab_view.selected-page.child as <$GhosttyTab>.active-surface as <$GhosttySurface>.pwd) as ; + subtitle: bind $computed_subtitle( + template.config, + tab_view.selected-page.child as <$GhosttyTab>.active-surface as <$GhosttySurface>.pwd + ) as ; }; [start] @@ -208,6 +211,11 @@ menu main_menu { } section { + item { + label: _("Change Window Title…"); + action: "win.prompt-window-title"; + } + item { label: _("New Window"); action: "win.new-window"; @@ -291,14 +299,25 @@ menu main_menu { action: "win.toggle-inspector"; } - item { - label: _("Open Configuration"); - action: "app.open-config"; - } + submenu { + label: _("Config"); - item { - label: _("Reload Configuration"); - action: "app.reload-config"; + item { + label: _("Open Configuration in OS Editor"); + action: "app.open-config"; + target: "os-open"; + } + + item { + label: _("Open Configuration in New Window"); + action: "app.open-config"; + target: "new-window"; + } + + item { + label: _("Reload Configuration"); + action: "app.reload-config"; + } } } diff --git a/vendor/libghostty-vt/src/apprt/gtk/weak_ref.zig b/vendor/libghostty-vt/src/apprt/gtk/weak_ref.zig index f689e45f..73cc6e51 100644 --- a/vendor/libghostty-vt/src/apprt/gtk/weak_ref.zig +++ b/vendor/libghostty-vt/src/apprt/gtk/weak_ref.zig @@ -22,6 +22,23 @@ pub fn WeakRef(comptime T: type) type { } } + /// Release this weak reference. + /// + /// You MUST call this before the memory holding this struct is freed, + /// which in practice means from the owner's `dispose`. The target keeps + /// a pointer to this `GWeakRef` so that it can clear it when the target + /// is finalized; if this memory is gone by then, the target walks into + /// whatever now occupies it. That is an invalid read at best, and can + /// hang: the target takes a lock inside each registered weak ref, and + /// reused memory with the low bit set is a lock nothing will release. + /// + /// `set(null)` also unregisters and remains valid. This exists so the + /// requirement has a name at the use site rather than looking like an + /// ordinary assignment. + pub fn deinit(self: *Self) void { + self.ref.clear(); + } + /// Get a strong reference to the object, or null if the object /// has been finalized. This increases the reference count by one. pub fn get(self: *Self) ?*T { diff --git a/vendor/libghostty-vt/src/apprt/gtk/winproto.zig b/vendor/libghostty-vt/src/apprt/gtk/winproto.zig index d409d6c2..abf46d99 100644 --- a/vendor/libghostty-vt/src/apprt/gtk/winproto.zig +++ b/vendor/libghostty-vt/src/apprt/gtk/winproto.zig @@ -8,6 +8,7 @@ const Config = @import("../../config.zig").Config; const input = @import("../../input.zig"); const key = @import("key.zig"); const ApprtWindow = @import("class/window.zig").Window; +const GlobalShortcuts = @import("class/global_shortcuts.zig").GlobalShortcuts; pub const noop = @import("winproto/noop.zig"); pub const x11 = @import("winproto/x11.zig"); @@ -78,6 +79,26 @@ pub const App = union(Protocol) { inline else => |*v| try v.initQuickTerminal(apprt_window), } } + + /// Bind all global keybinds through a mechanism native to the + /// windowing protocol. Returns false if there is none; the caller + /// should then fall back to the XDG desktop portal. + pub fn bindGlobalShortcuts( + self: *App, + shortcuts: *GlobalShortcuts, + config: *const Config, + ) bool { + return switch (self.*) { + inline else => |*v| v.bindGlobalShortcuts(shortcuts, config), + }; + } + + /// Unbind all global keybinds bound via bindGlobalShortcuts. + pub fn clearGlobalShortcuts(self: *App) void { + switch (self.*) { + inline else => |*v| v.clearGlobalShortcuts(), + } + } }; /// Per-Window state for the underlying windowing protocol. @@ -141,7 +162,7 @@ pub const Window = union(Protocol) { }; } - pub fn addSubprocessEnv(self: *Window, env: *std.process.EnvMap) !void { + pub fn addSubprocessEnv(self: *Window, env: *std.process.Environ.Map) !void { switch (self.*) { inline else => |*v| try v.addSubprocessEnv(env), } diff --git a/vendor/libghostty-vt/src/apprt/gtk/winproto/noop.zig b/vendor/libghostty-vt/src/apprt/gtk/winproto/noop.zig index 950ee0f3..e5f59dc4 100644 --- a/vendor/libghostty-vt/src/apprt/gtk/winproto/noop.zig +++ b/vendor/libghostty-vt/src/apprt/gtk/winproto/noop.zig @@ -6,6 +6,7 @@ const gdk = @import("gdk"); const Config = @import("../../../config.zig").Config; const input = @import("../../../input.zig"); const ApprtWindow = @import("../class/window.zig").Window; +const GlobalShortcuts = @import("../class/global_shortcuts.zig").GlobalShortcuts; const log = std.log.scoped(.winproto_noop); @@ -35,6 +36,16 @@ pub const App = struct { return false; } pub fn initQuickTerminal(_: *App, _: *ApprtWindow) !void {} + + pub fn bindGlobalShortcuts( + _: *App, + _: *GlobalShortcuts, + _: *const Config, + ) bool { + return false; + } + + pub fn clearGlobalShortcuts(_: *App) void {} }; pub const Window = struct { @@ -67,7 +78,7 @@ pub const Window = struct { return true; } - pub fn addSubprocessEnv(_: *Window, _: *std.process.EnvMap) !void {} + pub fn addSubprocessEnv(_: *Window, _: *std.process.Environ.Map) !void {} pub fn setUrgent(_: *Window, _: bool) !void {} }; diff --git a/vendor/libghostty-vt/src/apprt/gtk/winproto/wayland.zig b/vendor/libghostty-vt/src/apprt/gtk/winproto/wayland.zig index 12c7fb8a..81481514 100644 --- a/vendor/libghostty-vt/src/apprt/gtk/winproto/wayland.zig +++ b/vendor/libghostty-vt/src/apprt/gtk/winproto/wayland.zig @@ -17,9 +17,12 @@ const xdg = wayland.client.xdg; const Config = @import("../../../config.zig").Config; const Globals = @import("wayland/Globals.zig"); +const Hotkeys = @import("wayland/Hotkeys.zig"); const input = @import("../../../input.zig"); const ApprtWindow = @import("../class/window.zig").Window; +const GlobalShortcuts = @import("../class/global_shortcuts.zig").GlobalShortcuts; const BlurRegion = @import("BlurRegion.zig"); +const gtk_version = @import("../gtk_version.zig"); const log = std.log.scoped(.winproto_wayland); @@ -27,6 +30,7 @@ const log = std.log.scoped(.winproto_wayland); pub const App = struct { display: *wl.Display, globals: *Globals, + hotkeys: Hotkeys, pub fn init( alloc: Allocator, @@ -35,7 +39,6 @@ pub const App = struct { config: *const Config, ) !?App { _ = config; - _ = app_id; const gdk_wayland_display = gobject.ext.cast( gdk_wayland.WaylandDisplay, @@ -49,16 +52,35 @@ pub const App = struct { const globals: *Globals = try .init(alloc, display); errdefer globals.deinit(); + var hotkeys: Hotkeys = try .init(alloc, app_id); + errdefer hotkeys.deinit(); + return .{ .display = display, .globals = globals, + .hotkeys = hotkeys, }; } pub fn deinit(self: *App) void { + self.hotkeys.deinit(); self.globals.deinit(); } + pub fn bindGlobalShortcuts( + self: *App, + shortcuts: *GlobalShortcuts, + config: *const Config, + ) bool { + const manager = self.globals.get(.vicinae_hotkey_manager) orelse return false; + self.hotkeys.bind(manager, shortcuts, config); + return true; + } + + pub fn clearGlobalShortcuts(self: *App) void { + self.hotkeys.clear(); + } + pub fn eventMods( _: *App, _: ?*gdk.Device, @@ -153,6 +175,12 @@ pub const Window = struct { }; const bg_effect: ?*ext.BackgroundEffectSurfaceV1 = bg: { + if (gtk_version.runtimeAtLeast(4, 23, 3)) { + // GTK 4.23.3 added an official way to do background blur, + // so we don't need to do anything on our own. + break :bg null; + } + const mgr = app.globals.get(.ext_background_effect) orelse break :bg null; @@ -225,7 +253,7 @@ pub const Window = struct { }; } - pub fn addSubprocessEnv(self: *Window, env: *std.process.EnvMap) !void { + pub fn addSubprocessEnv(self: *Window, env: *std.process.Environ.Map) !void { _ = self; _ = env; } @@ -278,7 +306,7 @@ pub const Window = struct { } const wl_region = try compositor.createRegion(); - errdefer if (wl_region) |r| r.destroy(); + defer wl_region.destroy(); for (region.slices.items) |s| wl_region.add( @intCast(s.x), @intCast(s.y), @@ -287,6 +315,7 @@ pub const Window = struct { ); bg.setBlurRegion(wl_region); + self.blur_region.deinit(self.globals.alloc); self.blur_region = region; } diff --git a/vendor/libghostty-vt/src/apprt/gtk/winproto/wayland/Globals.zig b/vendor/libghostty-vt/src/apprt/gtk/winproto/wayland/Globals.zig index 83052cbe..bb4a48c9 100644 --- a/vendor/libghostty-vt/src/apprt/gtk/winproto/wayland/Globals.zig +++ b/vendor/libghostty-vt/src/apprt/gtk/winproto/wayland/Globals.zig @@ -8,6 +8,7 @@ const wl = wayland.client.wl; const ext = wayland.client.ext; const kde = wayland.client.kde; const org = wayland.client.org; +const vicinae = wayland.client.vicinae; const xdg = wayland.client.xdg; const log = std.log.scoped(.winproto_wayland_globals); @@ -32,6 +33,7 @@ pub const Tag = enum { kde_decoration_manager, kde_slide_manager, kde_output_order, + vicinae_hotkey_manager, xdg_activation, fn Type(comptime self: Tag) type { @@ -41,6 +43,7 @@ pub const Tag = enum { .kde_decoration_manager => org.KdeKwinServerDecorationManager, .kde_slide_manager => org.KdeKwinSlideManager, .kde_output_order => kde.OutputOrderV1, + .vicinae_hotkey_manager => vicinae.HotkeyManagerV1, .xdg_activation => xdg.ActivationV1, }; } diff --git a/vendor/libghostty-vt/src/apprt/gtk/winproto/wayland/Hotkeys.zig b/vendor/libghostty-vt/src/apprt/gtk/winproto/wayland/Hotkeys.zig new file mode 100644 index 00000000..91e54646 --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/winproto/wayland/Hotkeys.zig @@ -0,0 +1,165 @@ +//! Global shortcuts backed by the vicinae-hotkey Wayland protocol. +const Hotkeys = @This(); + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const wayland = @import("wayland"); +const vicinae = wayland.client.vicinae; + +const Config = @import("../../../../config.zig").Config; +const Binding = @import("../../../../input.zig").Binding; +const key = @import("../../key.zig"); +const GlobalShortcuts = @import("../../class/global_shortcuts.zig").GlobalShortcuts; + +const log = std.log.scoped(.winproto_wayland_hotkeys); + +alloc: Allocator, +app_id: [:0]const u8, +entries: std.ArrayList(Entry) = .empty, + +const Entry = struct { + /// Null once the binding was denied or revoked. + hotkey: ?*vicinae.HotkeyV1, + trigger: Binding.Trigger, + action: Binding.Action, + shortcuts: *GlobalShortcuts, + + fn deinit(self: *Entry) void { + if (self.hotkey) |hotkey| hotkey.destroy(); + } + + fn bind( + self: *Entry, + manager: *vicinae.HotkeyManagerV1, + app_id: [:0]const u8, + ) !void { + // The only time this will return an error is when + // `trigger.key` is `catch_all`, which is guarded + // in the public `bind` function + const keysym = key.keysymFromTrigger(self.trigger) orelse unreachable; + + var desc_buf: [256]u8 = undefined; + const desc = std.fmt.bufPrintZ(&desc_buf, "{f}", .{self.action}) catch ""; + + const hotkey = try manager.bind( + keysym, + .{ + .shift = self.trigger.mods.shift, + .ctrl = self.trigger.mods.ctrl, + .alt = self.trigger.mods.alt, + .super = self.trigger.mods.super, + }, + null, + app_id.ptr, + desc.ptr, + ); + errdefer hotkey.destroy(); + hotkey.setListener(*Entry, hotkeyListener, self); + self.hotkey = hotkey; + } + + fn fail(self: *Entry, message: [*:0]const u8, revoked: bool) void { + self.hotkey.?.destroy(); + self.hotkey = null; + + self.shortcuts.emitBindFailed(&.{ + .trigger = self.trigger, + .action = self.action, + .message = message, + .revoked = revoked, + }); + } +}; + +pub fn init(alloc: Allocator, app_id: [:0]const u8) Allocator.Error!Hotkeys { + return .{ + .alloc = alloc, + .app_id = try alloc.dupeZ(u8, app_id), + }; +} + +/// Must leave the entries in a valid empty state: clear may still be +/// called after deinit during application teardown. +pub fn deinit(self: *Hotkeys) void { + for (self.entries.items) |*entry| entry.deinit(); + self.entries.clearAndFree(self.alloc); + self.alloc.free(self.app_id); +} + +pub fn clear(self: *Hotkeys) void { + for (self.entries.items) |*entry| entry.deinit(); + self.entries.clearRetainingCapacity(); +} + +pub fn bind( + self: *Hotkeys, + manager: *vicinae.HotkeyManagerV1, + shortcuts: *GlobalShortcuts, + config: *const Config, +) void { + self.clear(); + + var it = config.keybind.set.bindings.iterator(); + while (it.next()) |entry| { + const leaf: Binding.Set.GenericLeaf = switch (entry.value_ptr.*) { + .leader => continue, + inline .leaf, .leaf_chained => |leaf| leaf.generic(), + }; + if (!leaf.flags.global) continue; + // Catch all global keybinds don't really make sense + if (entry.key_ptr.key == .catch_all) continue; + + // Only single-action global keybinds are supported, as in the + // portal implementation. + const actions = leaf.actionsSlice(); + if (actions.len != 1) continue; + + self.entries.append(self.alloc, .{ + .hotkey = null, + .trigger = entry.key_ptr.*, + .action = actions[0], + .shortcuts = shortcuts, + }) catch {}; + } + + for (self.entries.items) |*entry| { + entry.bind(manager, self.app_id) catch |err| { + log.warn("failed to request hotkey trigger={f} err={}", .{ + entry.trigger, + err, + }); + }; + } +} + +fn hotkeyListener( + _: *vicinae.HotkeyV1, + event: vicinae.HotkeyV1.Event, + entry: *Entry, +) void { + switch (event) { + .bound => log.debug("hotkey bound action={f}", .{entry.action}), + + .denied => |v| { + log.warn("hotkey denied action={f} reason={} message={s}", .{ + entry.action, + v.reason, + v.message, + }); + entry.fail(v.message, false); + }, + + .revoked => |v| { + log.warn("hotkey revoked action={f} reason={} message={s}", .{ + entry.action, + v.reason, + v.message, + }); + entry.fail(v.message, true); + }, + + .pressed => entry.shortcuts.emitTrigger(&entry.action), + .released => {}, + } +} diff --git a/vendor/libghostty-vt/src/apprt/gtk/winproto/wayland/protocols/vicinae-hotkey-v1.xml b/vendor/libghostty-vt/src/apprt/gtk/winproto/wayland/protocols/vicinae-hotkey-v1.xml new file mode 100644 index 00000000..30f97394 --- /dev/null +++ b/vendor/libghostty-vt/src/apprt/gtk/winproto/wayland/protocols/vicinae-hotkey-v1.xml @@ -0,0 +1,305 @@ + + + + Copyright © 2026 Aurelien Brabant + + 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 the Software without restriction, including without limitation + the rights to use, copy, modify, merge, publish, distribute, sublicense, + and/or sell copies of the Software, and to permit persons to whom the + Software is furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice (including the next + paragraph) shall be included in all copies or substantial portions of the + Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL + THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + DEALINGS IN THE SOFTWARE. + + + + This protocol lets a client choose, and freely reconfigure, its own + global hotkeys: it requests a specific key combination and the compositor + accepts it or rejects it with a reason. The compositor remains the eventual + arbiter (it may deny a request, revoke a binding at any time, or apply + whatever policy it likes), but within that the application manages its own + bindings, including changing them at runtime, with no out-of-band user + configuration and no persisted compositor state. + + A hotkey is a key combination that fires regardless of which surface holds + keyboard focus. This is unrelated to keyboard-shortcuts-inhibit, which lets a + focused client suppress the compositor's own shortcuts. + + Whether a combination is exclusive is compositor policy. A compositor MAY + treat combinations as exclusive and reject a clashing request with + "already_bound", or MAY grant the same combination to more than one binding + (each bound hotkey then receives the events), as some platforms do. Clients + must cope with either. + + It is intentionally a thin mechanism. It does NOT define which combinations + are acceptable: that is policy and belongs to the compositor, expressed + through the accept/deny channel. It does NOT persist hotkeys: a binding lives + only as long as its vicinae_hotkey_v1 object and the client connection; there is + no configure or storage step, a binding never outlives the client that created + it, and reconfiguring is simply destroying a hotkey and binding the new + combination. + + Warning! The protocol described in this file is currently in the testing + phase. Backward incompatible changes may be added together with the + corresponding interface version bump. Backward compatible changes are added + by bumping the interface version. + + Security considerations: + + A global hotkey lets an unfocused client observe that a specific key + combination was pressed. Compositors are the security boundary and SHOULD + apply policy when deciding whether to accept a request. In particular, + implementations SHOULD reject combinations that do not include at least one + non-latching modifier among Ctrl, Alt or Super, unless the trigger is a + function key, because unmodified (and Shift-only) keys carry ordinary text + entry and grabbing them turns this protocol into a keylogger. + + The protocol's one hard guarantee is containment: a compositor MUST NOT + deliver events for a combination the client did not successfully bind, and + never forwards the raw key stream, so a client learns nothing about input it + did not explicitly, and successfully, bind. Beyond that, a compositor MAY + apply any further policy it sees fit (for example prompting the user, + restricting which clients may bind, or limiting how many bindings a client may + hold) and MAY revoke a binding at any time via the "revoked" event, including + under user control. + + These are recommendations, not wire requirements: a combination the + compositor disallows is simply reported through the "denied" event with the + "not_permitted" reason, so applications can rely on a uniform rejection path + regardless of each compositor's policy. + + + + + This interface is the entry point of the protocol. It is used to request + global hotkeys. + + + + + A bitmask of the modifiers that must be held for the hotkey to fire. + + These are fixed, keymap-independent semantic bits naming the standard + modifiers, suitable for expressing a binding, unlike wl_keyboard.modifiers, + which carries opaque, keymap-derived masks for runtime state. A compositor + matches each bit against the corresponding modifier in its keyboard state + as the keymap defines it; for the xkb_v1 keymap format that is shift -> + XKB_MOD_NAME_SHIFT, ctrl -> XKB_MOD_NAME_CTRL, alt -> XKB_MOD_NAME_ALT + ("Mod1"), super -> XKB_MOD_NAME_LOGO ("Mod4"). + + Lock modifiers (Caps Lock, Num Lock) are never part of a binding and are + ignored when matching. + + + + + + + + + + Destroy the manager object. Hotkey objects created through this manager + are unaffected and remain valid; their bindings persist until they are + themselves destroyed or the client disconnects. + + + + + + Request that the given key combination be bound as a global hotkey. + + The keysym identifies the trigger key using the keysym numbering shared + with the keymap delivered over wl_keyboard (for the xkb_v1 keymap format, + an XKB_KEY_* value), taken in its unshifted form (XKB_KEY_b not XKB_KEY_B). A compositor SHOULD match + it against any of the user's layout groups, so a binding keeps firing after + the user switches layout group. This is one deliberately specified rule, + not a universal one: other platforms differ (macOS matches a physical + keycode, X11 re-grabs per layout, Windows tracks the active layout's key), + and no rule serves a layout that never produces the keysym; the single + documented rule is chosen for predictability across compositors. + + The request creates an vicinae_hotkey_v1 object immediately, but the binding + is not active yet. The compositor replies asynchronously with either the + "bound" event (the hotkey is now active) or the "denied" event (the + request was rejected, with a reason). + + seat is the wl_seat whose keyboard should trigger the hotkey, or null to + request it on all of the client's seats. Most clients pass null; seat is + provided for completeness on multi-seat systems. + + app_id identifies the requesting application, for use in the compositor's + policy and any user-facing audit UI. Where the application has a desktop + entry, app_id SHOULD be its desktop file ID as defined by the + freedesktop.org Desktop Entry specification, that is the .desktop file + name with the .desktop suffix removed (for example "org.example.Launcher"), + matching the convention used by xdg_toplevel.set_app_id so a compositor + can correlate the two. It is advisory and may be spoofed; a compositor + that needs a trustworthy identity SHOULD obtain it through the + security-context mechanism rather than relying on this string. + + description is a human-readable, localized description of what the hotkey + does (e.g. "Toggle the launcher"), for display in compositor UI or even in this protocol error messages. + + + + + + + + + + + + + Represents one requested global hotkey. Its binding is ephemeral: it is + released when this object is destroyed or when the client disconnects, and + it is never written to any persistent store. + + After bind, exactly one of "bound" or "denied" is sent. While the hotkey is + bound, "pressed" and "released" are sent as the combination is activated. + The compositor may send "revoked" at any time after "bound" to indicate the + binding is no longer active (for example because the user removed it, or a + higher-priority binding took the combination). + + + + + Carried by the "denied" event. This is a reason code, not a fatal + protocol error: the object remains valid and the client should destroy + it (or try a different combination). + + + + + + + + + Carried by the "revoked" event. This is a reason code, not a fatal + protocol error: the object remains valid and the client should destroy + it. + + The distinction is actionable: on "superseded" the combination may + become available again, so a client may reasonably re-request it later; + on "removed" the withdrawal is a deliberate user or compositor decision, + so a client MUST NOT silently re-request the same combination. A client + that receives a value it does not recognise (a future addition) MUST + treat it conservatively as "removed" and not auto-rebind. + + + + + + + + + Release the binding and destroy the object. The combination becomes + available again immediately. It is valid to destroy the object before + receiving "bound" or "denied", which cancels the pending request. + + + + + + The requested combination was accepted and is now active. "pressed" and + "released" events may follow. + + + + + + The requested combination was not bound. No input events will be sent. + The client should destroy this object; it may then create a new request + with a different combination. + + message is an optional, advisory, human-readable explanation in the + compositor's locale, intended for verbatim display in client UI (for + example a "change shortcut" settings screen). It may be empty, and a + compositor is never required to provide it. Clients MUST NOT parse it or + rely on its contents in any way; all programmatic behavior keys off + reason. A client with nowhere to show it simply ignores it. + + + + + + + + A binding that was previously "bound" is no longer active. No further + input events will be sent for it. The client should destroy this object; + it may request the combination again later. + + message is an optional, advisory, human-readable explanation in the + compositor's locale, intended for verbatim display in client UI. It may + be empty, and a compositor is never required to provide it. Clients MUST + NOT parse it or rely on its contents in any way; all programmatic + behavior keys off reason. A client with nowhere to show it simply + ignores it. + + + + + + + + The bound combination was activated. + + This event is sent once per activation. While the combination is held + down no further "pressed" events are sent, and a single "released" + follows when the trigger key is released: the compositor does not + auto-repeat the hotkey. + + serial is a compositor-issued input serial that counts as a recent user + interaction, so the client can act on the hotkey even though the + triggering key press is not otherwise delivered to it (the compositor + consumes it). In particular, a client may pass this serial to + xdg_activation_token_v1.set_serial in order to raise or focus a surface in + response to the hotkey; a compositor SHOULD honor activation carried by + this serial, since the hotkey is itself a deliberate user action. The + serial is drawn from the same space as other input event serials. + + time is the event timestamp, with the same millisecond clock as wl_pointer + and wl_keyboard input events; it is useful for ordering and for measuring + press-to-release duration. + + + + + + + + The trigger key of a previously pressed combination was released. This + enables press-and-hold uses (e.g. push-to-talk); clients that only act + on activation may ignore it. + + serial and time have the same meaning as in the "pressed" event. + + + + + + diff --git a/vendor/libghostty-vt/src/apprt/gtk/winproto/x11.zig b/vendor/libghostty-vt/src/apprt/gtk/winproto/x11.zig index 8109959d..fe0e6f61 100644 --- a/vendor/libghostty-vt/src/apprt/gtk/winproto/x11.zig +++ b/vendor/libghostty-vt/src/apprt/gtk/winproto/x11.zig @@ -19,6 +19,7 @@ pub const c = @cImport({ const input = @import("../../../input.zig"); const Config = @import("../../../config.zig").Config; const ApprtWindow = @import("../class/window.zig").Window; +const GlobalShortcuts = @import("../class/global_shortcuts.zig").GlobalShortcuts; const BlurRegion = @import("BlurRegion.zig"); const log = std.log.scoped(.gtk_x11); @@ -158,6 +159,16 @@ pub const App = struct { return mods; } + pub fn bindGlobalShortcuts( + _: *App, + _: *GlobalShortcuts, + _: *const Config, + ) bool { + return false; + } + + pub fn clearGlobalShortcuts(_: *App) void {} + pub fn supportsQuickTerminal(_: App) bool { log.warn("quick terminal is not yet supported on X11", .{}); return false; @@ -317,7 +328,7 @@ pub const Window = struct { self.last_applied_decoration_hints = hints; } - pub fn addSubprocessEnv(self: *Window, env: *std.process.EnvMap) !void { + pub fn addSubprocessEnv(self: *Window, env: *std.process.Environ.Map) !void { var buf: [64]u8 = undefined; const window_id = try std.fmt.bufPrint( &buf, diff --git a/vendor/libghostty-vt/src/apprt/ipc.zig b/vendor/libghostty-vt/src/apprt/ipc.zig index dda794ae..d5d860a7 100644 --- a/vendor/libghostty-vt/src/apprt/ipc.zig +++ b/vendor/libghostty-vt/src/apprt/ipc.zig @@ -70,23 +70,82 @@ pub const Action = union(enum) { // entry. If the value type is void then only the key needs to be // added. Ensure the order matches exactly with the Zig code. - /// The arguments to pass to Ghostty as the command. + /// Create a new window. new_window: NewWindow, + /// Create a new tab. + new_tab: NewTab, + /// Toggle the quick terminal. toggle_quick_terminal: void, pub const NewWindow = struct { - /// A list of command arguments to launch in the new window. If this is - /// `null` the command configured in the config or the user's default - /// shell should be launched. + /// A list of configuration entries that will override the configuration + /// of the first surface of the new window. If this is `null` there + /// are no overrides. Note that not all configuration entries may be + /// overridden. /// /// It is an error for this to be non-`null`, but zero length. arguments: ?[][:0]const u8, pub const C = extern struct { - /// null terminated list of arguments - /// it will be null itself if there are no arguments + /// A list of configuration entries that will override the + /// configuration of the first surface of the new tab. If this is + /// `null` there are no overrides. Note that not all configuration + /// entries may be overridden. + /// + /// It is an error for this to be non-`null`, but zero length. + arguments: ?[*]?[*:0]const u8, + + pub fn deinit(self: *NewWindow.C, alloc: Allocator) void { + if (self.arguments) |arguments| alloc.free(arguments); + } + }; + + pub fn cval(self: *NewWindow, alloc: Allocator) Allocator.Error!NewWindow.C { + var result: NewWindow.C = undefined; + + if (self.arguments) |arguments| { + result.arguments = try alloc.alloc([*:0]const u8, arguments.len + 1); + + for (arguments, 0..) |argument, i| + result.arguments[i] = argument.ptr; + + // add null terminator + result.arguments[arguments.len] = null; + } else { + result.arguments = null; + } + + return result; + } + }; + + pub const NewTab = struct { + /// The unique ID of a surface. If the ID is zero the currently focused + /// surface is assumed. This will be used to identify the window to add + /// the tab to. + surface_id: u64, + + /// A list of configuration entries that will override the configuration + /// of the first surface of the new tab. If this is `null` there + /// are no overrides. Note that not all configuration entries may be + /// overridden. + /// + /// It is an error for this to be non-`null`, but zero length. + arguments: ?[][:0]const u8, + + pub const C = extern struct { + /// The unique ID of a surface. If the ID is zero the currently + /// focused surface is assumed. This will be used to identify the + /// window to add the tab to. + surface_id: u64, + /// A list of configuration entries that will override the + /// configuration of the first surface of the new tab. If this is + /// `null` there are no overrides. Note that not all configuration + /// entries may be overridden. + /// + /// It is an error for this to be non-`null`, but zero length. arguments: ?[*]?[*:0]const u8, pub fn deinit(self: *NewWindow.C, alloc: Allocator) void { @@ -116,6 +175,7 @@ pub const Action = union(enum) { /// Sync with: ghostty_ipc_action_tag_e pub const Key = enum(c_int) { new_window, + new_tab, toggle_quick_terminal, test "ghostty.h Action.Key" { @@ -126,8 +186,12 @@ pub const Action = union(enum) { /// Sync with: ghostty_ipc_action_u pub const CValue = cvalue: { const key_fields = @typeInfo(Key).@"enum".fields; - var union_fields: [key_fields.len]std.builtin.Type.UnionField = undefined; - for (key_fields, 0..) |field, i| { + + var names: [key_fields.len][]const u8 = undefined; + var types: [key_fields.len]type = undefined; + var attrs: [key_fields.len]std.builtin.Type.UnionField.Attributes = undefined; + + for (key_fields, &names, &types, &attrs) |field, *name, *ty, *attr| { const action = @unionInit(Action, field.name, undefined); const Type = t: { const Type = @TypeOf(@field(action, field.name)); @@ -135,20 +199,12 @@ pub const Action = union(enum) { if (Type != void and @hasDecl(Type, "C")) break :t Type.C; break :t Type; }; - - union_fields[i] = .{ - .name = field.name, - .type = Type, - .alignment = @alignOf(Type), - }; + name.* = field.name; + ty.* = Type; + attr.* = .{ .@"align" = @alignOf(Type) }; } - break :cvalue @Type(.{ .@"union" = .{ - .layout = .@"extern", - .tag_type = null, - .fields = &union_fields, - .decls = &.{}, - } }); + break :cvalue @Union(.@"extern", null, &names, &types, &attrs); }; /// Sync with: ghostty_ipc_action_s @@ -162,8 +218,8 @@ pub const Action = union(enum) { // At the time of writing, we don't promise ABI compatibility // so we can change this but I want to be aware of it. assert(@sizeOf(CValue) == switch (@sizeOf(usize)) { - 4 => 4, - 8 => 8, + 4 => 12, + 8 => 16, else => unreachable, }); } diff --git a/vendor/libghostty-vt/src/apprt/structs.zig b/vendor/libghostty-vt/src/apprt/structs.zig index 2c37dbd5..d8510fb8 100644 --- a/vendor/libghostty-vt/src/apprt/structs.zig +++ b/vendor/libghostty-vt/src/apprt/structs.zig @@ -1,4 +1,6 @@ +const std = @import("std"); const build_config = @import("../build_config.zig"); +const terminal = @import("../terminal/main.zig"); /// ContentScale is the ratio between the current DPI and the platform's /// default DPI. This is used to determine how much certain rendered elements @@ -67,13 +69,35 @@ pub const ClipboardRequestType = enum(u8) { paste, osc_52_read, osc_52_write, + kitty_read, + kitty_write, + list, +}; + +/// The result of starting a clipboard read request. This only reports +/// facts about the clipboard; how each state is answered on the wire +/// (if at all) is up to the protocol handling of the requester. +/// +/// If this is changed, you must also update ghostty.h +pub const ClipboardReadResult = enum(c_int) { + /// The request was started and will be completed asynchronously + /// via the core surface completeClipboardRequest API. + started = 0, + + /// The clipboard exists but has no contents the apprt can serve + /// (e.g. no text-like data). The request was not started. + unavailable = 1, + + /// The clipboard itself can't be read (e.g. a primary selection on + /// a platform without one). The request was not started. + unsupported = 2, }; /// Clipboard request. This is used to request clipboard contents and must /// be sent as a response to a ClipboardRequest event. pub const ClipboardRequest = union(ClipboardRequestType) { /// A direct paste of clipboard contents. - paste: void, + paste: Clipboard, /// A request to read clipboard contents via OSC 52. osc_52_read: Clipboard, @@ -81,6 +105,113 @@ pub const ClipboardRequest = union(ClipboardRequestType) { /// A request to write clipboard contents via OSC 52. osc_52_write: Clipboard, + /// A request to read clipboard contents via the Kitty clipboard + /// protocol (OSC 5522). + kitty_read: *KittyRead, + + /// A request to write clipboard contents via the Kitty clipboard + /// protocol (OSC 5522), carrying a fully committed transaction. + kitty_write: *KittyWrite, + + /// A request to list the available clipboard MIME types without + /// reading any of their data. + list: Clipboard, + + /// State for one in-flight Kitty clipboard protocol read. This is + /// created on the IO thread and completed on the app thread, so it + /// owns all of its memory: everything, including the struct itself, + /// is allocated from the arena. + pub const KittyRead = struct { + arena: std.heap.ArenaAllocator, + + /// The clipboard being read. The protocol can only name the + /// standard clipboard or the primary selection. + location: Clipboard, + + /// The requested MIME types in request order, already capped at + /// terminal.kitty.clipboard.max_read_mimes by the sender. Only + /// these representations may be served in the response. The + /// values are sentinel-terminated so they can cross a C apprt + /// boundary without copies. + mimes: []const [:0]const u8, + + /// True when the targets ('.') listing was requested. + list: bool, + + /// The sanitized request id, echoed in every response packet. + id: []const u8, + + /// The effective session password, empty when the request had + /// none. A non-empty password means the user's decision may be + /// remembered as a session grant. + pw: []const u8, + + /// The human friendly name of the requesting program, shown in + /// permission prompts. Empty when absent. Sentinel-terminated + /// so it can cross a C apprt boundary without copies. + name: [:0]const u8, + + /// True when a stored session grant already covers this + /// request, so any permission prompt is skipped. + granted: bool, + + /// The response terminator, matching the request's. + terminator: terminal.osc.Terminator, + + pub fn destroy(self: *KittyRead) void { + // The struct itself lives in the arena, so move the arena + // out before tearing it down. + var arena = self.arena; + arena.deinit(); + } + }; + + /// State for one committed Kitty clipboard protocol write + /// transaction. Like KittyRead, this is created on the IO thread + /// and completed on the app thread, so everything, including the + /// struct itself, is allocated from the arena. + pub const KittyWrite = struct { + arena: std.heap.ArenaAllocator, + + /// The clipboard being written. The protocol can only name the + /// standard clipboard or the primary selection. + location: Clipboard, + + /// The committed representations. These are the authoritative + /// contents of the write: completions apply these rather than + /// any contents echoed back by the apprt. The values are + /// sentinel-terminated so they can cross a C apprt boundary + /// without copies; data is binary-safe via its length. + contents: []const ClipboardContent, + + /// The sanitized request id, echoed in every response packet. + id: []const u8, + + /// The effective session password, empty when the request had + /// none. A non-empty password means the user's decision may be + /// remembered as a session grant. + pw: []const u8, + + /// The human friendly name of the requesting program, shown in + /// permission prompts. Empty when absent. Sentinel-terminated + /// so it can cross a C apprt boundary without copies. + name: [:0]const u8, + + /// True when a stored session grant already covers this + /// request, so any permission prompt is skipped. + granted: bool, + + /// The response terminator, matching the request's. + terminator: terminal.osc.Terminator, + + pub fn destroy(self: *KittyWrite) void { + // The struct itself lives in the arena, so move the arena + // out before tearing it down. + var arena = self.arena; + arena.deinit(); + } + }; + /// Make this a valid gobject if we're in a GTK environment. pub const getGObjectType = switch (build_config.app_runtime) { .gtk => @import("gobject").ext.defineBoxed( diff --git a/vendor/libghostty-vt/src/apprt/surface.zig b/vendor/libghostty-vt/src/apprt/surface.zig index 3cb0016f..51da3374 100644 --- a/vendor/libghostty-vt/src/apprt/surface.zig +++ b/vendor/libghostty-vt/src/apprt/surface.zig @@ -16,6 +16,47 @@ pub const Message = union(enum) { /// we want this union to be. pub const WriteReq = MessageData(u8, 255); + /// A fixed-size desktop notification payload sent to the app thread. + pub const DesktopNotification = struct { + /// Desktop notification title. + title: [63:0]u8, + + /// Desktop notification body. + body: [255:0]u8, + + pub fn init(title: []const u8, body: []const u8) DesktopNotification { + var result: DesktopNotification = undefined; + copyUtf8Z(result.title.len, &result.title, title); + copyUtf8Z(result.body.len, &result.body, body); + return result; + } + + /// UTF-8 continuation bytes occupy the range 0x80 through 0xBF. + fn isUtf8ContinuationByte(byte: u8) bool { + return switch (byte) { + 0x80...0xBF => true, + else => false, + }; + } + + /// Copy as much of `src` as fits, backing up from a UTF-8 continuation + /// byte so valid input is never truncated in the middle of a codepoint. + fn copyUtf8Z( + comptime capacity: usize, + dst: *[capacity:0]u8, + src: []const u8, + ) void { + var len = @min(src.len, capacity); + while (len > 0 and + len < src.len and + isUtf8ContinuationByte(src[len])) : (len -= 1) + {} + + @memcpy(dst[0..len], src[0..len]); + dst[len] = 0; + } + }; + /// Set the title of the surface. /// TODO: we should change this to a "WriteReq" style structure in /// the termio message so that we can more efficiently send strings @@ -31,6 +72,16 @@ pub const Message = union(enum) { /// Read the clipboard and write to the pty. clipboard_read: apprt.Clipboard, + /// A Kitty clipboard protocol (OSC 5522) read request. The receiver + /// takes ownership of the request state and must eventually destroy + /// it. + kitty_clipboard_read: *apprt.ClipboardRequest.KittyRead, + + /// A committed Kitty clipboard protocol (OSC 5522) write + /// transaction. The receiver takes ownership of the request state + /// and must eventually destroy it. + kitty_clipboard_write: *apprt.ClipboardRequest.KittyWrite, + /// Write the clipboard contents. clipboard_write: struct { clipboard_type: apprt.Clipboard, @@ -52,13 +103,7 @@ pub const Message = union(enum) { child_exited: ChildExited, /// Show a desktop notification. - desktop_notification: struct { - /// Desktop notification title. - title: [63:0]u8, - - /// Desktop notification body. - body: [255:0]u8, - }, + desktop_notification: DesktopNotification, /// Health status change for the renderer. renderer_health: renderer.Health, @@ -195,3 +240,78 @@ pub fn newConfig( return copy; } + +test "DesktopNotification init" { + const notification = Message.DesktopNotification.init("Title", "Body"); + + try std.testing.expectEqualStrings("Title", std.mem.sliceTo(¬ification.title, 0)); + try std.testing.expectEqualStrings("Body", std.mem.sliceTo(¬ification.body, 0)); +} + +test "copyUtf8Z handles len at the final byte of every UTF-8 sequence length" { + const DesktopNotification = Message.DesktopNotification; + + var dst_1_byte: [1:0]u8 = undefined; + const src_ending_in_1_byte_codepoint = "ab"; + try std.testing.expect(!DesktopNotification.isUtf8ContinuationByte( + src_ending_in_1_byte_codepoint[dst_1_byte.len], + )); + DesktopNotification.copyUtf8Z( + dst_1_byte.len, + &dst_1_byte, + src_ending_in_1_byte_codepoint, + ); + try std.testing.expectEqualStrings("a", std.mem.sliceTo(&dst_1_byte, 0)); + + var dst_2_bytes: [2:0]u8 = undefined; + const src_ending_in_2_byte_codepoint = "aЯ"; + try std.testing.expect(DesktopNotification.isUtf8ContinuationByte( + src_ending_in_2_byte_codepoint[dst_2_bytes.len], + )); + DesktopNotification.copyUtf8Z( + dst_2_bytes.len, + &dst_2_bytes, + src_ending_in_2_byte_codepoint, + ); + try std.testing.expectEqualStrings("a", std.mem.sliceTo(&dst_2_bytes, 0)); + + var dst_3_bytes: [3:0]u8 = undefined; + const src_ending_in_3_byte_codepoint = "a€"; + try std.testing.expect(DesktopNotification.isUtf8ContinuationByte( + src_ending_in_3_byte_codepoint[dst_3_bytes.len], + )); + DesktopNotification.copyUtf8Z( + dst_3_bytes.len, + &dst_3_bytes, + src_ending_in_3_byte_codepoint, + ); + try std.testing.expectEqualStrings("a", std.mem.sliceTo(&dst_3_bytes, 0)); + + var dst_4_bytes: [4:0]u8 = undefined; + const src_ending_in_4_byte_codepoint = "a😀"; + try std.testing.expect(DesktopNotification.isUtf8ContinuationByte( + src_ending_in_4_byte_codepoint[dst_4_bytes.len], + )); + DesktopNotification.copyUtf8Z( + dst_4_bytes.len, + &dst_4_bytes, + src_ending_in_4_byte_codepoint, + ); + try std.testing.expectEqualStrings("a", std.mem.sliceTo(&dst_4_bytes, 0)); +} + +test "copyUtf8Z keeps a complete codepoint at the truncation boundary" { + var dst: [5:0]u8 = undefined; + + Message.DesktopNotification.copyUtf8Z(dst.len, &dst, "abcЯz"); + + try std.testing.expectEqualStrings("abcЯ", std.mem.sliceTo(&dst, 0)); +} + +test "copyUtf8Z preserves UTF-8 that fits" { + var dst: [5:0]u8 = undefined; + + Message.DesktopNotification.copyUtf8Z(dst.len, &dst, "abcЯ"); + + try std.testing.expectEqualStrings("abcЯ", std.mem.sliceTo(&dst, 0)); +} diff --git a/vendor/libghostty-vt/src/benchmark/ApcParser.zig b/vendor/libghostty-vt/src/benchmark/ApcParser.zig index 73b7c57a..9efdc4fb 100644 --- a/vendor/libghostty-vt/src/benchmark/ApcParser.zig +++ b/vendor/libghostty-vt/src/benchmark/ApcParser.zig @@ -12,6 +12,7 @@ const Allocator = std.mem.Allocator; const terminalpkg = @import("../terminal/main.zig"); const Benchmark = @import("Benchmark.zig"); const options = @import("options.zig"); +const global = @import("../global.zig"); const log = std.log.scoped(.@"apc-parser-bench"); @@ -19,7 +20,7 @@ opts: Options, stream: Stream, /// The file, opened in the setup function. -data_f: ?std.fs.File = null, +data_f: ?std.Io.File = null, pub const Options = struct { /// The data to read as a filepath. If this is "-" then @@ -51,10 +52,10 @@ const Handler = struct { .apc_start => self.apc.start(), .apc_put => self.apc.feed(self.alloc, value), .apc_put_slice => self.apc.feedSlice(self.alloc, value.bytes), - .apc_end => if (self.apc.end()) |cmd| { - var c = cmd; - std.mem.doNotOptimizeAway(&c); - c.deinit(self.alloc); + .apc_end => if (self.apc.end()) |result| { + var r = result; + std.mem.doNotOptimizeAway(&r); + r.deinit(self.alloc); }, else => {}, } @@ -70,7 +71,10 @@ pub fn create( errdefer alloc.destroy(ptr); ptr.* = .{ .opts = opts, - .stream = .init(.{ .alloc = alloc }), + .stream = .init(.{ + .allocator = alloc, + .handler = .{ .alloc = alloc }, + }), }; return ptr; } @@ -103,7 +107,7 @@ fn setup(ptr: *anyopaque) Benchmark.Error!void { fn teardown(ptr: *anyopaque) void { const self: *ApcParser = @ptrCast(@alignCast(ptr)); if (self.data_f) |f| { - f.close(); + f.close(global.io()); self.data_f = null; } } @@ -114,7 +118,7 @@ fn step(ptr: *anyopaque) Benchmark.Error!void { const f = self.data_f orelse return; var read_buf: [64 * 1024]u8 align(std.atomic.cache_line) = undefined; - var f_reader = f.reader(&read_buf); + var f_reader = f.reader(global.io(), &read_buf); const r = &f_reader.interface; // This buffer size matches the read buffer size used by the diff --git a/vendor/libghostty-vt/src/benchmark/Benchmark.zig b/vendor/libghostty-vt/src/benchmark/Benchmark.zig index 41c3695d..d45ba181 100644 --- a/vendor/libghostty-vt/src/benchmark/Benchmark.zig +++ b/vendor/libghostty-vt/src/benchmark/Benchmark.zig @@ -6,6 +6,7 @@ const builtin = @import("builtin"); const assert = std.debug.assert; const macos = @import("macos"); const build_config = @import("../build_config.zig"); +const global = @import("../global.zig"); ptr: *anyopaque, vtable: VTable, @@ -64,21 +65,23 @@ pub fn run( signpost.log.release(); }; - const start = std.time.Instant.now() catch return error.BenchmarkFailed; + const start: std.Io.Timestamp = .now(global.io(), .awake); while (true) { // Run our step function. If it fails, we return the error. try self.vtable.stepFn(self.ptr); result.iterations += 1; // Get our current monotonic time and check our exit conditions. - const now = std.time.Instant.now() catch return error.BenchmarkFailed; + const now: std.Io.Timestamp = .now(global.io(), .awake); + const elapsed = start.durationTo(now).nanoseconds; + assert(elapsed >= 0); const exit = switch (mode) { .once => true, - .duration => |ns| now.since(start) >= ns, + .duration => |ns| elapsed >= ns, }; if (exit) { - result.duration = now.since(start); + result.duration = @as(u64, @intCast(std.math.clamp(elapsed, 0, std.math.maxInt(u64)))); return result; } } diff --git a/vendor/libghostty-vt/src/benchmark/CApi.zig b/vendor/libghostty-vt/src/benchmark/CApi.zig index 3bef8b26..c9840671 100644 --- a/vendor/libghostty-vt/src/benchmark/CApi.zig +++ b/vendor/libghostty-vt/src/benchmark/CApi.zig @@ -1,6 +1,6 @@ const std = @import("std"); const cli = @import("cli.zig"); -const state = &@import("../global.zig").state; +const global = @import("../global.zig"); const log = std.log.scoped(.benchmark); @@ -19,7 +19,7 @@ export fn ghostty_benchmark_cli( }; cli.mainAction( - state.alloc, + global.alloc(), action, .{ .string = std.mem.sliceTo(args, 0) }, ) catch |err| { diff --git a/vendor/libghostty-vt/src/benchmark/CodepointWidth.zig b/vendor/libghostty-vt/src/benchmark/CodepointWidth.zig index 30d3f91e..d0c6b808 100644 --- a/vendor/libghostty-vt/src/benchmark/CodepointWidth.zig +++ b/vendor/libghostty-vt/src/benchmark/CodepointWidth.zig @@ -14,13 +14,14 @@ const options = @import("options.zig"); const UTF8Decoder = @import("../terminal/UTF8Decoder.zig"); const simd = @import("../simd/main.zig"); const table = @import("../unicode/main.zig").table; +const global = @import("../global.zig"); const log = std.log.scoped(.@"terminal-stream-bench"); opts: Options, /// The file, opened in the setup function. -data_f: ?std.fs.File = null, +data_f: ?std.Io.File = null, pub const Options = struct { /// The type of codepoint width calculation to use. @@ -93,7 +94,7 @@ fn setup(ptr: *anyopaque) Benchmark.Error!void { fn teardown(ptr: *anyopaque) void { const self: *CodepointWidth = @ptrCast(@alignCast(ptr)); if (self.data_f) |f| { - f.close(); + f.close(global.io()); self.data_f = null; } } @@ -114,7 +115,7 @@ fn stepWcwidth(ptr: *anyopaque) Benchmark.Error!void { const f = self.data_f orelse return; var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined; - var f_reader = f.reader(&read_buf); + var f_reader = f.reader(global.io(), &read_buf); var r = &f_reader.interface; var d: UTF8Decoder = .{}; @@ -141,7 +142,7 @@ fn stepTable(ptr: *anyopaque) Benchmark.Error!void { const f = self.data_f orelse return; var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined; - var f_reader = f.reader(&read_buf); + var f_reader = f.reader(global.io(), &read_buf); var r = &f_reader.interface; var d: UTF8Decoder = .{}; @@ -173,7 +174,7 @@ fn stepSimd(ptr: *anyopaque) Benchmark.Error!void { const f = self.data_f orelse return; var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined; - var f_reader = f.reader(&read_buf); + var f_reader = f.reader(global.io(), &read_buf); var r = &f_reader.interface; var d: UTF8Decoder = .{}; diff --git a/vendor/libghostty-vt/src/benchmark/GraphemeBreak.zig b/vendor/libghostty-vt/src/benchmark/GraphemeBreak.zig index 8278c5c2..8e5236fe 100644 --- a/vendor/libghostty-vt/src/benchmark/GraphemeBreak.zig +++ b/vendor/libghostty-vt/src/benchmark/GraphemeBreak.zig @@ -11,13 +11,14 @@ const options = @import("options.zig"); const UTF8Decoder = @import("../terminal/UTF8Decoder.zig"); const unicode = @import("../unicode/main.zig"); const uucode = @import("uucode"); +const global = @import("../global.zig"); const log = std.log.scoped(.@"terminal-stream-bench"); opts: Options, /// The file, opened in the setup function. -data_f: ?std.fs.File = null, +data_f: ?std.Io.File = null, pub const Options = struct { /// The type of codepoint width calculation to use. @@ -82,7 +83,7 @@ fn setup(ptr: *anyopaque) Benchmark.Error!void { fn teardown(ptr: *anyopaque) void { const self: *GraphemeBreak = @ptrCast(@alignCast(ptr)); if (self.data_f) |f| { - f.close(); + f.close(global.io()); self.data_f = null; } } @@ -92,7 +93,7 @@ fn stepNoop(ptr: *anyopaque) Benchmark.Error!void { const f = self.data_f orelse return; var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined; - var f_reader = f.reader(&read_buf); + var f_reader = f.reader(global.io(), &read_buf); var r = &f_reader.interface; var d: UTF8Decoder = .{}; @@ -115,7 +116,7 @@ fn stepTable(ptr: *anyopaque) Benchmark.Error!void { const f = self.data_f orelse return; var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined; - var f_reader = f.reader(&read_buf); + var f_reader = f.reader(global.io(), &read_buf); var r = &f_reader.interface; var d: UTF8Decoder = .{}; diff --git a/vendor/libghostty-vt/src/benchmark/IsSymbol.zig b/vendor/libghostty-vt/src/benchmark/IsSymbol.zig index 4fbffd1e..c99bc9ca 100644 --- a/vendor/libghostty-vt/src/benchmark/IsSymbol.zig +++ b/vendor/libghostty-vt/src/benchmark/IsSymbol.zig @@ -11,13 +11,14 @@ const options = @import("options.zig"); const UTF8Decoder = @import("../terminal/UTF8Decoder.zig"); const uucode = @import("uucode"); const symbols_table = @import("../unicode/symbols_table.zig").table; +const global = @import("../global.zig"); const log = std.log.scoped(.@"is-symbol-bench"); opts: Options, /// The file, opened in the setup function. -data_f: ?std.fs.File = null, +data_f: ?std.Io.File = null, pub const Options = struct { /// Which test to run. @@ -80,7 +81,7 @@ fn setup(ptr: *anyopaque) Benchmark.Error!void { fn teardown(ptr: *anyopaque) void { const self: *IsSymbol = @ptrCast(@alignCast(ptr)); if (self.data_f) |f| { - f.close(); + f.close(global.io()); self.data_f = null; } } @@ -90,7 +91,7 @@ fn stepUucode(ptr: *anyopaque) Benchmark.Error!void { const f = self.data_f orelse return; var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined; - var f_reader = f.reader(&read_buf); + var f_reader = f.reader(global.io(), &read_buf); var r = &f_reader.interface; var d: UTF8Decoder = .{}; @@ -117,7 +118,7 @@ fn stepTable(ptr: *anyopaque) Benchmark.Error!void { const f = self.data_f orelse return; var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined; - var f_reader = f.reader(&read_buf); + var f_reader = f.reader(global.io(), &read_buf); var r = &f_reader.interface; var d: UTF8Decoder = .{}; diff --git a/vendor/libghostty-vt/src/benchmark/OscParser.zig b/vendor/libghostty-vt/src/benchmark/OscParser.zig index d4b416de..9b21c37a 100644 --- a/vendor/libghostty-vt/src/benchmark/OscParser.zig +++ b/vendor/libghostty-vt/src/benchmark/OscParser.zig @@ -9,11 +9,12 @@ const Benchmark = @import("Benchmark.zig"); const options = @import("options.zig"); const Parser = @import("../terminal/osc.zig").Parser; const log = std.log.scoped(.@"osc-parser-bench"); +const global = @import("../global.zig"); opts: Options, /// The file, opened in the setup function. -data_f: ?std.fs.File = null, +data_f: ?std.Io.File = null, parser: Parser, @@ -70,7 +71,7 @@ fn setup(ptr: *anyopaque) Benchmark.Error!void { fn teardown(ptr: *anyopaque) void { const self: *OscParser = @ptrCast(@alignCast(ptr)); if (self.data_f) |f| { - f.close(); + f.close(global.io()); self.data_f = null; } } @@ -80,7 +81,7 @@ fn step(ptr: *anyopaque) Benchmark.Error!void { const f = self.data_f orelse return; var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined; - var r = f.reader(&read_buf); + var r = f.reader(global.io(), &read_buf); var osc_buf: [4096]u8 align(std.atomic.cache_line) = undefined; while (true) { diff --git a/vendor/libghostty-vt/src/benchmark/PageCompression.zig b/vendor/libghostty-vt/src/benchmark/PageCompression.zig index 21153659..23ecf0bf 100644 --- a/vendor/libghostty-vt/src/benchmark/PageCompression.zig +++ b/vendor/libghostty-vt/src/benchmark/PageCompression.zig @@ -67,6 +67,8 @@ const options = @import("options.zig"); const compress = @import("../terminal/compress.zig"); const CompressedPage = compress.Page; const lz4 = compress.lz4; +const compat_file = @import("../lib/compat/file.zig"); +const global = @import("../global.zig"); const log = std.log.scoped(.@"page-compression-bench"); @@ -210,9 +212,9 @@ fn setupData(self: *PageCompression) !void { return error.InvalidRetainedPages; const data_file = try options.dataFile(self.opts.data) orelse return; - defer data_file.close(); + defer data_file.close(global.io()); - self.data = try data_file.readToEndAlloc(self.alloc, max_data_size); + self.data = try compat_file.readToEndAlloc(data_file, self.alloc, max_data_size); errdefer { self.alloc.free(self.data); self.data = &.{}; diff --git a/vendor/libghostty-vt/src/benchmark/ScreenClone.zig b/vendor/libghostty-vt/src/benchmark/ScreenClone.zig index 5176ce26..9b2012cc 100644 --- a/vendor/libghostty-vt/src/benchmark/ScreenClone.zig +++ b/vendor/libghostty-vt/src/benchmark/ScreenClone.zig @@ -11,6 +11,7 @@ const terminalpkg = @import("../terminal/main.zig"); const Benchmark = @import("Benchmark.zig"); const options = @import("options.zig"); const Terminal = terminalpkg.Terminal; +const global = @import("../global.zig"); const log = std.log.scoped(.@"terminal-stream-bench"); @@ -79,7 +80,7 @@ pub fn create( ptr.* = .{ .opts = opts, - .terminal = try .init(alloc, .{ + .terminal = try .init(global.io(), alloc, .{ .rows = opts.@"terminal-rows", .cols = opts.@"terminal-cols", }), @@ -122,7 +123,7 @@ fn setup(ptr: *anyopaque) Benchmark.Error!void { s.nextSlice("hello"); // Setup our terminal state - const data_f: std.fs.File = (options.dataFile( + const data_f: std.Io.File = (options.dataFile( self.opts.data, ) catch |err| { log.warn("error opening data file err={}", .{err}); @@ -133,7 +134,7 @@ fn setup(ptr: *anyopaque) Benchmark.Error!void { defer stream.deinit(); var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined; - var f_reader = data_f.reader(&read_buf); + var f_reader = data_f.reader(global.io(), &read_buf); const r = &f_reader.interface; var buf: [4096]u8 = undefined; @@ -171,6 +172,7 @@ fn stepClone(ptr: *anyopaque) Benchmark.Error!void { for (0..1000) |_| { const s: *terminalpkg.Screen = self.terminal.screens.active; const copy = s.clone( + s.io, s.alloc, .{ .viewport = .{} }, null, diff --git a/vendor/libghostty-vt/src/benchmark/ScrollbackCompression.zig b/vendor/libghostty-vt/src/benchmark/ScrollbackCompression.zig index 40ec20f4..d971c551 100644 --- a/vendor/libghostty-vt/src/benchmark/ScrollbackCompression.zig +++ b/vendor/libghostty-vt/src/benchmark/ScrollbackCompression.zig @@ -74,6 +74,7 @@ const Benchmark = @import("Benchmark.zig"); const options = @import("options.zig"); const PageList = terminalpkg.PageList; const Terminal = terminalpkg.Terminal; +const global = @import("../global.zig"); const log = std.log.scoped(.@"scrollback-compression-bench"); @@ -134,10 +135,10 @@ pub fn create( ptr.* = .{ .opts = opts, - .terminal = try .init(alloc, .{ + .terminal = try .init(global.io(), alloc, .{ .rows = opts.@"terminal-rows", .cols = opts.@"terminal-cols", - .max_scrollback = opts.@"max-scrollback", + .max_scrollback_bytes = opts.@"max-scrollback", }), }; return ptr; @@ -182,13 +183,13 @@ fn setup(ptr: *anyopaque) Benchmark.Error!void { /// terminal-stream benchmark. Parser and file IO costs remain in setup. fn loadCorpus(self: *ScrollbackCompression) !void { const data_file = try options.dataFile(self.opts.data) orelse return; - defer data_file.close(); + defer data_file.close(global.io()); var stream = self.terminal.vtStream(); defer stream.deinit(); var read_buf: [64 * 1024]u8 align(std.atomic.cache_line) = undefined; - var file_reader = data_file.reader(&read_buf); + var file_reader = data_file.reader(global.io(), &read_buf); const reader = &file_reader.interface; var buf: [64 * 1024]u8 = undefined; diff --git a/vendor/libghostty-vt/src/benchmark/TerminalFormatter.zig b/vendor/libghostty-vt/src/benchmark/TerminalFormatter.zig new file mode 100644 index 00000000..fb763ab5 --- /dev/null +++ b/vendor/libghostty-vt/src/benchmark/TerminalFormatter.zig @@ -0,0 +1,482 @@ +//! Benchmarks the terminal formatter (`terminal/formatter.zig`). +//! +//! The formatter is the hot path for clipboard copy (plain/VT/HTML), +//! `write_screen_file`, `selectionString`, terminal search window +//! encoding, and the libghostty-vt formatter C API. This benchmark +//! measures formatting terminal contents that were built during setup +//! (outside the timed region) from a pre-generated VT stream. +//! +//! ## Input +//! +//! `--data` names a pre-generated VT byte stream (for example from +//! `ghostty-gen styled`). The stream is fed to a terminal of the +//! requested dimensions with unlimited scrollback during setup. The +//! resulting screen contents (scrollback included) are what each step +//! formats. +//! +//! ## Modes +//! +//! * `noop` performs no formatting and establishes loop/setup overhead. +//! Subtract this from `format` timings when using hyperfine. +//! * `format` formats the configured region once per loop into a +//! reusable buffer. Buffer growth happens on the first iteration only. +//! * `report` formats once and prints content/output sizes. It is for +//! computing throughput (cells/s, bytes/s), not timing comparisons. +//! +//! ## Examples +//! +//! Build benchmarks in ReleaseFast mode: +//! +//! zig build -Demit-bench -Doptimize=ReleaseFast -Demit-macos-app=false +//! +//! Generate a deterministic corpus, then measure: +//! +//! ghostty-gen styled --seed=42 | head -c 640000 > /tmp/plain.vt +//! hyperfine --warmup 3 \ +//! 'ghostty-bench +terminal-formatter --mode=noop --data=/tmp/plain.vt' \ +//! 'ghostty-bench +terminal-formatter --emit=plain --loops=50 --data=/tmp/plain.vt' \ +//! 'ghostty-bench +terminal-formatter --emit=vt --loops=50 --data=/tmp/plain.vt' +const TerminalFormatter = @This(); + +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const terminalpkg = @import("../terminal/main.zig"); +const formatterpkg = terminalpkg.formatter; +const Benchmark = @import("Benchmark.zig"); +const options = @import("options.zig"); +const Terminal = terminalpkg.Terminal; +const Selection = terminalpkg.Selection; +const global = @import("../global.zig"); + +const log = std.log.scoped(.@"terminal-formatter-bench"); + +alloc: Allocator, +opts: Options, +terminal: ?Terminal = null, + +/// Reused across steps so buffer growth is a one-time setup cost. +output: std.Io.Writer.Allocating, + +/// Reused pin map storage for `--pin-map=true`. +pins: formatterpkg.PinMap.Map = .empty, + +pub const Options = struct { + /// Set by the shared CLI parser for string option ownership. + _arena: ?std.heap.ArenaAllocator = null, + + /// Select the operation performed inside the timed benchmark step. + mode: Mode = .format, + + /// The output format to emit. + emit: Emit = .vt, + + /// The region of the screen to format. + region: Region = .screen, + + /// Unwrap soft-wrapped lines. + unwrap: bool = false, + + /// Track the source pin of every emitted byte. This exercises the + /// (documented as expensive) pin_map path used by selectionString + /// and search. + @"pin-map": bool = false, + + /// Number of format operations per benchmark step. Increase this + /// when the content is too small for stable hyperfine measurements. + loops: u32 = 25, + + /// The size of the terminal. This affects wrapping and page sizes. + @"terminal-rows": u16 = 24, + @"terminal-cols": u16 = 80, + + /// Pre-generated VT stream fed to the terminal during setup. `-` + /// reads stdin, although a regular file is recommended so identical + /// state can be reused across runs. When unset, the terminal is + /// empty. + data: ?[]const u8 = null, + + pub fn deinit(self: *Options) void { + if (self._arena) |arena| arena.deinit(); + self.* = undefined; + } +}; + +pub const Mode = enum { + /// Establish the benchmark loop and setup overhead. + noop, + + /// Format the configured region once per loop. + format, + + /// Print content and output sizes. Not a timing benchmark. + report, + + /// Semantic verification, not a timing benchmark: format the + /// terminal (must be `--emit=vt`), feed the output into a fresh + /// terminal of the same dimensions, format that, and verify the + /// two outputs converge to identical bytes. This proves the VT + /// output faithfully reconstructs the terminal content even when + /// the exact byte encoding changes. + roundtrip, +}; + +pub const Emit = enum { + plain, + vt, + html, + + fn format(self: Emit) formatterpkg.Format { + return switch (self) { + .plain => .plain, + .vt => .vt, + .html => .html, + }; + } +}; + +pub const Region = enum { + /// Everything: scrollback and active screen. + screen, + + /// Only the active screen (bottom rows). + active, + + /// Only the scrollback. + history, +}; + +pub fn create( + alloc: Allocator, + opts: Options, +) !*TerminalFormatter { + const ptr = try alloc.create(TerminalFormatter); + errdefer alloc.destroy(ptr); + ptr.* = .{ + .alloc = alloc, + .opts = opts, + .output = .init(alloc), + }; + return ptr; +} + +pub fn destroy(self: *TerminalFormatter, alloc: Allocator) void { + if (self.terminal) |*t| t.deinit(self.alloc); + self.output.deinit(); + self.pins.deinit(self.alloc); + alloc.destroy(self); +} + +pub fn benchmark(self: *TerminalFormatter) Benchmark { + return .init(self, .{ + .stepFn = switch (self.opts.mode) { + .noop => stepNoop, + .format => stepFormat, + .report => stepReport, + .roundtrip => stepRoundtrip, + }, + .setupFn = setup, + .teardownFn = teardown, + }); +} + +/// Build the terminal state every mode shares. All of this is outside +/// the timed region for `Benchmark`, but is included in whole-process +/// timings, hence the `noop` mode. +fn setup(ptr: *anyopaque) Benchmark.Error!void { + const self: *TerminalFormatter = @ptrCast(@alignCast(ptr)); + self.setupImpl() catch |err| { + log.warn("failed to prepare formatter benchmark err={}", .{err}); + return error.BenchmarkFailed; + }; +} + +fn setupImpl(self: *TerminalFormatter) !void { + if (self.terminal) |*t| t.deinit(self.alloc); + self.terminal = null; + self.terminal = try Terminal.init(global.io(), self.alloc, .{ + .cols = self.opts.@"terminal-cols", + .rows = self.opts.@"terminal-rows", + .max_scrollback_bytes = null, + .max_scrollback_lines = null, + }); + const terminal = &self.terminal.?; + + // Feed the input corpus through the standard VT stream. + if (try options.dataFile(self.opts.data)) |data_f| { + defer data_f.close(global.io()); + + var stream = terminal.vtStream(); + defer stream.deinit(); + + var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined; + var f_reader = data_f.reader(global.io(), &read_buf); + const r = &f_reader.interface; + + var buf: [4096]u8 = undefined; + while (true) { + const n = try r.readSliceShort(&buf); + if (n == 0) break; // EOF reached + stream.nextSlice(buf[0..n]); + } + } +} + +fn teardown(ptr: *anyopaque) void { + const self: *TerminalFormatter = @ptrCast(@alignCast(ptr)); + if (self.terminal) |*t| t.deinit(self.alloc); + self.terminal = null; + self.output.shrinkRetainingCapacity(0); + self.pins.clearRetainingCapacity(); +} + +/// Build the screen formatter matching our options. This mirrors how +/// Surface clipboard copy and write_screen_file construct formatters. +fn formatter(self: *TerminalFormatter) ?formatterpkg.ScreenFormatter { + const screen = self.terminal.?.screens.active; + + var f: formatterpkg.ScreenFormatter = .init(screen, .{ + .emit = self.opts.emit.format(), + .unwrap = self.opts.unwrap, + }); + + f.content = switch (self.opts.region) { + .screen => .{ .selection = null }, + + inline .active, .history => |region| content: { + const tag: terminalpkg.point.Tag = switch (region) { + .active => .active, + .history => .history, + .screen => unreachable, + }; + const tl = screen.pages.getTopLeft(tag); + const br = screen.pages.getBottomRight(tag) orelse return null; + break :content .{ .selection = Selection.init(tl, br, false) }; + }, + }; + + return f; +} + +fn stepNoop(ptr: *anyopaque) Benchmark.Error!void { + const self: *TerminalFormatter = @ptrCast(@alignCast(ptr)); + for (0..self.opts.loops) |_| { + std.mem.doNotOptimizeAway(self.output.written()); + } +} + +fn stepFormat(ptr: *anyopaque) Benchmark.Error!void { + const self: *TerminalFormatter = @ptrCast(@alignCast(ptr)); + for (0..self.opts.loops) |_| { + self.output.shrinkRetainingCapacity(0); + + var f = self.formatter() orelse continue; + if (self.opts.@"pin-map") { + self.pins.clearRetainingCapacity(); + f.pin_map = .{ .alloc = self.alloc, .map = &self.pins }; + } + + f.format(&self.output.writer) catch |err| { + log.warn("formatting failed err={}", .{err}); + return error.BenchmarkFailed; + }; + std.mem.doNotOptimizeAway(self.output.written()); + } +} + +/// Print the content dimensions and emitted output size. This shares +/// the formatting code with format mode but deliberately makes no +/// timing claims. Use it to compute cells/s and bytes/s from timings. +fn stepReport(ptr: *anyopaque) Benchmark.Error!void { + const self: *TerminalFormatter = @ptrCast(@alignCast(ptr)); + + self.output.shrinkRetainingCapacity(0); + if (self.formatter()) |f_init| { + var f = f_init; + if (self.opts.@"pin-map") { + self.pins.clearRetainingCapacity(); + f.pin_map = .{ .alloc = self.alloc, .map = &self.pins }; + } + f.format(&self.output.writer) catch |err| { + log.warn("formatting failed err={}", .{err}); + return error.BenchmarkFailed; + }; + } + + // Count the total pages and rows in the pagelist. + const screen = self.terminal.?.screens.active; + var pages: usize = 0; + var rows: usize = 0; + { + var node = screen.pages.pages.first; + while (node) |n| : (node = n.next) { + pages += 1; + rows += n.page().size.rows; + } + } + + // Hash the output (and the pin coordinates, which are stable across + // runs unlike the node pointers) so different implementations can be + // checked for identical output. + const out_hash = std.hash.Wyhash.hash(0, self.output.written()); + var pin_hasher = std.hash.Wyhash.init(0); + for (self.pins.points.items) |coord| { + const x: u16 = coord.x; + const y: u16 = @intCast(coord.y); + pin_hasher.update(std.mem.asBytes(&x)); + pin_hasher.update(std.mem.asBytes(&y)); + } + + std.debug.print( + "terminal-formatter emit={s} region={s} pages={d} rows={d} " ++ + "cols={d} cells={d} out_bytes={d} pin_bytes={d} " ++ + "out_hash={x} pin_hash={x}\n", + .{ + @tagName(self.opts.emit), + @tagName(self.opts.region), + pages, + rows, + self.opts.@"terminal-cols", + rows * self.opts.@"terminal-cols", + self.output.written().len, + self.pins.count(), + out_hash, + pin_hasher.final(), + }, + ); + + // Pin map storage details on a separate line so the main report + // line remains comparable across implementations. + if (self.opts.@"pin-map") { + std.debug.print( + "terminal-formatter-pins points={d} nodes={d}\n", + .{ self.pins.points.items.len, self.pins.nodes.items.len }, + ); + } +} + +fn stepRoundtrip(ptr: *anyopaque) Benchmark.Error!void { + const self: *TerminalFormatter = @ptrCast(@alignCast(ptr)); + self.stepRoundtripImpl() catch |err| { + log.warn("roundtrip failed err={}", .{err}); + return error.BenchmarkFailed; + }; +} + +/// Format the terminal, replay the output into a fresh terminal of the +/// same dimensions, format that, and require both outputs to be +/// identical. This verifies that the emitted VT sequences faithfully +/// reconstruct the terminal contents (text, styles, wrapping) without +/// requiring any specific byte encoding of the first output. +fn stepRoundtripImpl(self: *TerminalFormatter) !void { + // Format the original terminal. + self.output.shrinkRetainingCapacity(0); + if (self.formatter()) |f_init| { + var f = f_init; + try f.format(&self.output.writer); + } + const first = self.output.written(); + + // Replay into a fresh terminal. + var t2 = try Terminal.init(global.io(), self.alloc, .{ + .cols = self.opts.@"terminal-cols", + .rows = self.opts.@"terminal-rows", + .max_scrollback_bytes = null, + .max_scrollback_lines = null, + }); + defer t2.deinit(self.alloc); + { + var stream = t2.vtStream(); + defer stream.deinit(); + stream.nextSlice(first); + } + + // Format the replayed terminal identically. + var out2: std.Io.Writer.Allocating = .init(self.alloc); + defer out2.deinit(); + var f2: formatterpkg.ScreenFormatter = .init(t2.screens.active, .{ + .emit = self.opts.emit.format(), + .unwrap = self.opts.unwrap, + }); + try f2.format(&out2.writer); + const second = out2.written(); + + const equal = std.mem.eql(u8, first, second); + std.debug.print( + "terminal-formatter roundtrip emit={s} bytes={d} replay_bytes={d} equal={}\n", + .{ @tagName(self.opts.emit), first.len, second.len, equal }, + ); + + if (!equal) { + // Find the first differing offset to ease debugging. + const n = @min(first.len, second.len); + var i: usize = 0; + while (i < n and first[i] == second[i]) i += 1; + std.debug.print( + "terminal-formatter roundtrip mismatch offset={d} " ++ + "first={f} second={f}\n", + .{ + i, + std.zig.fmtString(first[i -| 32..@min(first.len, i + 32)]), + std.zig.fmtString(second[i -| 32..@min(second.len, i + 32)]), + }, + ); + return error.RoundtripMismatch; + } +} + +test TerminalFormatter { + const testing = std.testing; + const impl: *TerminalFormatter = try .create(testing.allocator, .{}); + defer impl.destroy(testing.allocator); + + const bench = impl.benchmark(); + _ = try bench.run(.once); +} + +test "TerminalFormatter roundtrip" { + const testing = std.testing; + const impl: *TerminalFormatter = try .create(testing.allocator, .{ + .mode = .roundtrip, + .@"terminal-rows" = 4, + .@"terminal-cols" = 8, + }); + defer impl.destroy(testing.allocator); + + const bench = impl.benchmark(); + _ = try bench.run(.once); +} + +test "TerminalFormatter formats all emit formats and regions" { + const testing = std.testing; + + inline for (.{ Emit.plain, Emit.vt, Emit.html }) |emit| { + inline for (.{ Region.screen, Region.active, Region.history }) |region| { + const impl: *TerminalFormatter = try .create(testing.allocator, .{ + .emit = emit, + .region = region, + .loops = 1, + .@"terminal-rows" = 4, + .@"terminal-cols" = 8, + }); + defer impl.destroy(testing.allocator); + + const bench = impl.benchmark(); + _ = try bench.run(.once); + } + } +} + +test "TerminalFormatter pin map" { + const testing = std.testing; + const impl: *TerminalFormatter = try .create(testing.allocator, .{ + .@"pin-map" = true, + .loops = 1, + .@"terminal-rows" = 4, + .@"terminal-cols" = 8, + }); + defer impl.destroy(testing.allocator); + + const bench = impl.benchmark(); + _ = try bench.run(.once); +} diff --git a/vendor/libghostty-vt/src/benchmark/TerminalParser.zig b/vendor/libghostty-vt/src/benchmark/TerminalParser.zig index 78c93312..d393301c 100644 --- a/vendor/libghostty-vt/src/benchmark/TerminalParser.zig +++ b/vendor/libghostty-vt/src/benchmark/TerminalParser.zig @@ -7,13 +7,14 @@ const Allocator = std.mem.Allocator; const terminalpkg = @import("../terminal/main.zig"); const Benchmark = @import("Benchmark.zig"); const options = @import("options.zig"); +const global = @import("../global.zig"); const log = std.log.scoped(.@"terminal-stream-bench"); opts: Options, /// The file, opened in the setup function. -data_f: ?std.fs.File = null, +data_f: ?std.Io.File = null, pub const Options = struct { /// The data to read as a filepath. If this is "-" then @@ -61,7 +62,7 @@ fn setup(ptr: *anyopaque) Benchmark.Error!void { fn teardown(ptr: *anyopaque) void { const self: *TerminalParser = @ptrCast(@alignCast(ptr)); if (self.data_f) |f| { - f.close(); + f.close(global.io()); self.data_f = null; } } @@ -76,7 +77,7 @@ fn step(ptr: *anyopaque) Benchmark.Error!void { // aren't currently IO bound. const f = self.data_f orelse return; var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined; - var f_reader = f.reader(&read_buf); + var f_reader = f.reader(global.io(), &read_buf); var r = &f_reader.interface; var p: terminalpkg.Parser = .init(); diff --git a/vendor/libghostty-vt/src/benchmark/TerminalResize.zig b/vendor/libghostty-vt/src/benchmark/TerminalResize.zig new file mode 100644 index 00000000..61b908c8 --- /dev/null +++ b/vendor/libghostty-vt/src/benchmark/TerminalResize.zig @@ -0,0 +1,357 @@ +//! This benchmark tests the performance of Terminal.resize, with a +//! primary focus on column resizes that reflow soft-wrapped text. +//! Resize happens on the IO thread while holding the terminal lock, +//! so a slow resize directly translates into dropped input and a +//! frozen-feeling UI while the user drags the window edge (which +//! produces a rapid stream of resizes). +//! +//! The terminal is populated once during setup (synthetic fill and/or +//! a data file replayed through the VT stream) and then each step +//! ping-pongs the terminal between two sizes. A full cycle returns the +//! terminal to its original dimensions so the state reaches a steady +//! state after the first cycle and every iteration performs +//! equivalent work. +const TerminalResize = @This(); + +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const terminalpkg = @import("../terminal/main.zig"); +const Benchmark = @import("Benchmark.zig"); +const options = @import("options.zig"); +const Terminal = terminalpkg.Terminal; +const global = @import("../global.zig"); + +const log = std.log.scoped(.@"terminal-resize-bench"); + +opts: Options, +alloc: Allocator, +terminal: Terminal, + +pub const Options = struct { + /// The resize pattern to benchmark. See Mode. + mode: Mode = .cols, + + /// Multiplier on the number of resize cycles each step runs. This + /// is useful to make a benchmark run long enough for profiling. + loops: u32 = 1, + + /// The initial size of the terminal. This is also the size that + /// every resize cycle returns to. + @"terminal-rows": u16 = 80, + @"terminal-cols": u16 = 120, + + /// The dimensions to resize to for the cols/rows/both modes. If + /// unset, they default to half of the respective terminal + /// dimension, which forces every soft-wrapped line to rewrap. + @"resize-cols": ?u16 = null, + @"resize-rows": ?u16 = null, + + /// The number of synthetic lines written to the terminal during + /// setup. The content is deterministic: a mix of short lines, + /// soft-wrapped long lines, blank lines, styled cells, and wide + /// characters, since all of these hit different reflow paths. + /// Set to 0 to only use `data`. + @"fill-lines": u32 = 10_000, + + /// The maximum scrollback size in bytes. Defaults to the Ghostty + /// application default. Reflow cost scales with the amount of + /// scrollback, not just the visible screen. + @"scrollback-bytes": usize = 50_000_000, + + /// The data to read as a filepath. If this is "-" then + /// we will read stdin. If this is unset, only the synthetic fill + /// is used. The data is streamed into the terminal during setup + /// (not part of the benchmark) to build the screen contents that + /// get resized. + data: ?[]const u8 = null, +}; + +pub const Mode = enum { + /// Resize to the current dimensions. This measures the early-exit + /// path (mode updates, pixel geometry) and acts as a baseline. + noop, + + /// Alternate the column count between `terminal-cols` and + /// `resize-cols`. With wraparound enabled (the default), this + /// reflows text in both directions: shrinking wraps long lines + /// and growing unwraps them. This is the primary reflow benchmark. + cols, + + /// Like `cols`, but with wraparound mode disabled so the resize + /// does not reflow. Useful as a baseline to isolate the cost of + /// reflow itself from the rest of the resize. + @"cols-no-reflow", + + /// Alternate the row count between `terminal-rows` and + /// `resize-rows`. Column count is unchanged so no text reflows; + /// this measures growing/trimming rows against scrollback. + rows, + + /// Alternate both dimensions at once, like a diagonal window drag. + both, +}; + +pub fn create( + alloc: Allocator, + opts: Options, +) !*TerminalResize { + const ptr = try alloc.create(TerminalResize); + errdefer alloc.destroy(ptr); + + ptr.* = .{ + .opts = opts, + .alloc = alloc, + .terminal = try .init(global.io(), alloc, .{ + .rows = opts.@"terminal-rows", + .cols = opts.@"terminal-cols", + .max_scrollback_bytes = opts.@"scrollback-bytes", + }), + }; + + return ptr; +} + +pub fn destroy(self: *TerminalResize, alloc: Allocator) void { + self.terminal.deinit(alloc); + alloc.destroy(self); +} + +pub fn benchmark(self: *TerminalResize) Benchmark { + return .init(self, .{ + .stepFn = switch (self.opts.mode) { + .noop => stepNoop, + .cols, .@"cols-no-reflow" => stepCols, + .rows => stepRows, + .both => stepBoth, + }, + .setupFn = setup, + }); +} + +/// The column count used by the cols/both modes. +fn targetCols(self: *const TerminalResize) u16 { + return self.opts.@"resize-cols" orelse + @max(1, self.opts.@"terminal-cols" / 2); +} + +/// The row count used by the rows/both modes. +fn targetRows(self: *const TerminalResize) u16 { + return self.opts.@"resize-rows" orelse + @max(1, self.opts.@"terminal-rows" / 2); +} + +fn setup(ptr: *anyopaque) Benchmark.Error!void { + const self: *TerminalResize = @ptrCast(@alignCast(ptr)); + + // Always reset our terminal state. Note this doesn't resize, but + // create initializes (and steps return) the terminal to the + // requested dimensions so we're always at terminal-rows/cols here. + self.terminal.fullReset(); + assert(self.terminal.cols == self.opts.@"terminal-cols"); + assert(self.terminal.rows == self.opts.@"terminal-rows"); + + // Fill with synthetic content first, then replay the data file on + // top if given. Both go through the VT stream so soft wraps, + // styles, etc. are all set exactly as they would be in a real + // session. + self.fill(); + try self.replayData(); + + // Reflow only happens when wraparound mode is set (it is by + // default). We only disable it after filling so the fill itself + // still soft-wraps identically in every mode. + if (self.opts.mode == .@"cols-no-reflow") { + self.terminal.modes.set(.wraparound, false); + } +} + +/// Write deterministic synthetic content to the terminal. The goal is +/// content that is representative of a real session so that reflow +/// touches its interesting paths: soft-wrapped lines (must rewrap), +/// short lines (copied as-is), blank lines, styled cells (styles must +/// be moved across pages), and wide characters (can't be split at the +/// wrap column). +fn fill(self: *TerminalResize) void { + if (self.opts.@"fill-lines" == 0) return; + + var s = self.terminal.vtStream(); + defer s.deinit(); + + var prng: std.Random.DefaultPrng = .init(0xB3); + const rand = prng.random(); + + const cols: usize = self.terminal.cols; + var line_buf: [8192]u8 = undefined; + + for (0..self.opts.@"fill-lines") |i| { + // Periodically toggle a background style so reflow has to + // carry styled cells into new pages. + if (i % 64 == 0) s.nextSlice("\x1b[48;2;20;40;60m"); + if (i % 64 == 32) s.nextSlice("\x1b[m"); + + // A small portion of lines are blank. + if (i % 16 == 15) { + s.nextSlice("\r\n"); + continue; + } + + // Line lengths between ~25% and ~250% of the terminal width + // so we get a mix of short lines and soft-wrapped lines. + const min = @max(1, cols / 4); + const max = @min(line_buf.len, cols * 5 / 2); + const len = min + rand.uintLessThan(usize, max - min); + + var j: usize = 0; + while (j < len) { + // Sprinkle wide characters into every 8th line. + if (i % 8 == 7 and j % 16 == 8 and j + 3 <= len) { + line_buf[j..][0..3].* = "漢".*; + j += 3; + continue; + } + + // Words of ASCII separated by spaces. + line_buf[j] = if (j % 8 == 7) + ' ' + else + rand.intRangeAtMost(u8, 'a', 'z'); + j += 1; + } + + s.nextSlice(line_buf[0..len]); + s.nextSlice("\r\n"); + } +} + +/// Stream the data file (if any) into the terminal. +fn replayData(self: *TerminalResize) Benchmark.Error!void { + const data_f: std.Io.File = (options.dataFile( + self.opts.data, + ) catch |err| { + log.warn("error opening data file err={}", .{err}); + return error.BenchmarkFailed; + }) orelse return; + defer data_f.close(global.io()); + + var stream = self.terminal.vtStream(); + defer stream.deinit(); + + var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined; + var f_reader = data_f.reader(global.io(), &read_buf); + const r = &f_reader.interface; + + var buf: [4096]u8 = undefined; + while (true) { + const n = r.readSliceShort(&buf) catch { + log.warn("error reading data file err={?}", .{f_reader.err}); + return error.BenchmarkFailed; + }; + if (n == 0) break; // EOF reached + stream.nextSlice(buf[0..n]); + } +} + +fn resizeTerminal( + self: *TerminalResize, + cols: u16, + rows: u16, +) Benchmark.Error!void { + self.terminal.resize(self.alloc, .{ + .cols = cols, + .rows = rows, + // Realistic cell pixel geometry: real apprt resizes always + // carry it, and it exercises the pixel dimension updates. + .cell_size_px = .{ .width = 10, .height = 20 }, + }) catch |err| { + log.warn("error resizing terminal err={}", .{err}); + return error.BenchmarkFailed; + }; + std.mem.doNotOptimizeAway(&self.terminal); +} + +fn stepNoop(ptr: *anyopaque) Benchmark.Error!void { + const self: *TerminalResize = @ptrCast(@alignCast(ptr)); + + const cols = self.opts.@"terminal-cols"; + const rows = self.opts.@"terminal-rows"; + + // We loop because it's so fast (a few ns) that a single resize + // doesn't properly capture our speeds. + for (0..50_000_000 * @as(u64, self.opts.loops)) |_| { + try self.resizeTerminal(cols, rows); + } +} + +fn stepCols(ptr: *anyopaque) Benchmark.Error!void { + const self: *TerminalResize = @ptrCast(@alignCast(ptr)); + + const cols = self.opts.@"terminal-cols"; + const rows = self.opts.@"terminal-rows"; + const target = self.targetCols(); + + // Per-cycle cost differs by orders of magnitude between the two + // modes (a reflow resize walks the entire scrollback; a non-reflow + // resize doesn't), so pick a cycle count that makes each run long + // enough to measure well above process startup and setup cost. + const cycles: u64 = switch (self.opts.mode) { + .cols => 25, + .@"cols-no-reflow" => 1_500, + else => unreachable, + }; + + // Each cycle shrinks (rewrapping long lines) and grows back + // (unwrapping them), ending at the original size. + for (0..cycles * @as(u64, self.opts.loops)) |_| { + try self.resizeTerminal(target, rows); + try self.resizeTerminal(cols, rows); + } +} + +fn stepRows(ptr: *anyopaque) Benchmark.Error!void { + const self: *TerminalResize = @ptrCast(@alignCast(ptr)); + + const cols = self.opts.@"terminal-cols"; + const rows = self.opts.@"terminal-rows"; + const target = self.targetRows(); + + // Row-only resizes don't reflow so they're much cheaper (tens of + // ns); loop a lot more so the measurement isn't dominated by + // process overhead. + for (0..2_500_000 * @as(u64, self.opts.loops)) |_| { + try self.resizeTerminal(cols, target); + try self.resizeTerminal(cols, rows); + } +} + +fn stepBoth(ptr: *anyopaque) Benchmark.Error!void { + const self: *TerminalResize = @ptrCast(@alignCast(ptr)); + + const cols = self.opts.@"terminal-cols"; + const rows = self.opts.@"terminal-rows"; + const target_cols = self.targetCols(); + const target_rows = self.targetRows(); + + for (0..25 * @as(u64, self.opts.loops)) |_| { + try self.resizeTerminal(target_cols, target_rows); + try self.resizeTerminal(cols, rows); + } +} + +test TerminalResize { + const testing = std.testing; + const alloc = testing.allocator; + + // Small dimensions and fill so this is fast in debug builds while + // still exercising real reflow in both directions. + const impl: *TerminalResize = try .create(alloc, .{ + .mode = .cols, + .@"terminal-rows" = 10, + .@"terminal-cols" = 20, + .@"fill-lines" = 50, + }); + defer impl.destroy(alloc); + + const bench = impl.benchmark(); + _ = try bench.run(.once); +} diff --git a/vendor/libghostty-vt/src/benchmark/TerminalSnapshot.zig b/vendor/libghostty-vt/src/benchmark/TerminalSnapshot.zig new file mode 100644 index 00000000..aea5e1f9 --- /dev/null +++ b/vendor/libghostty-vt/src/benchmark/TerminalSnapshot.zig @@ -0,0 +1,350 @@ +//! Benchmarks the terminal binary snapshot codecs (`terminal/snapshot`). +//! +//! Encoding and decoding a snapshot are the hot paths for terminal +//! persistence and handoff, so both directions are measured against the +//! same terminal state. +//! +//! ## Input +//! +//! `--data` names a pre-generated VT byte stream (for example from +//! `ghostty-gen ascii`). The stream is fed to a terminal of the requested +//! dimensions during setup, outside the timed region. The resulting screen +//! and scrollback contents are what each step encodes or decodes. +//! +//! ## Modes +//! +//! * `noop` performs no codec work and establishes loop overhead. +//! * `encode` encodes one complete snapshot per loop into a reusable +//! buffer. Buffer growth happens on the first iteration only. +//! * `decode` restores one complete snapshot per loop from bytes prepared +//! during setup. The restored terminal is destroyed inside the step, so +//! this measures the complete restore lifecycle. +//! * `decode-ready` restores only the renderable prefix per loop using the +//! incremental decoder and stops at READY, skipping history. Compared +//! against `decode`, this is the time-to-interactive win of applying +//! history off the critical path. +//! * `report` encodes once and prints total and per-record-tag sizes. It +//! is for inspecting the wire shape, not timing comparisons. +//! +//! ## Examples +//! +//! Build benchmarks in ReleaseFast mode: +//! +//! zig build -Demit-bench -Doptimize=ReleaseFast -Demit-macos-app=false +//! +//! Generate a deterministic corpus, then measure both directions: +//! +//! ghostty-gen ascii | head -c 1000000 > /tmp/ascii.vt +//! hyperfine --warmup 3 \ +//! 'ghostty-bench +terminal-snapshot --mode=encode --loops=20 --data=/tmp/ascii.vt' \ +//! 'ghostty-bench +terminal-snapshot --mode=decode --loops=20 --data=/tmp/ascii.vt' +const TerminalSnapshot = @This(); + +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const terminalpkg = @import("../terminal/main.zig"); +const snapshot = terminalpkg.snapshot; +const Benchmark = @import("Benchmark.zig"); +const options = @import("options.zig"); +const Terminal = terminalpkg.Terminal; +const global = @import("../global.zig"); + +const log = std.log.scoped(.@"terminal-snapshot-bench"); + +alloc: Allocator, +opts: Options, +terminal: ?Terminal = null, + +/// Reused across encode steps so buffer growth is a one-time setup cost. +encoded: std.Io.Writer.Allocating, + +pub const Options = struct { + /// Set by the shared CLI parser for string option ownership. + _arena: ?std.heap.ArenaAllocator = null, + + /// Select the operation performed inside the timed benchmark step. + mode: Mode = .encode, + + /// Number of codec operations per benchmark step. Increase this when + /// the state is too small for stable `hyperfine` measurements. + loops: u32 = 1, + + /// The size of the terminal. This affects wrapping, page sizes, and + /// the amount of state per encoded page. + @"terminal-rows": u16 = 24, + @"terminal-cols": u16 = 80, + + /// Pre-generated VT stream fed to the terminal during setup. `-` reads + /// stdin, although a regular file is recommended so identical state can + /// be reused across runs. When unset, the terminal is empty. + data: ?[]const u8 = null, + + pub fn deinit(self: *Options) void { + if (self._arena) |arena| arena.deinit(); + self.* = undefined; + } +}; + +pub const Mode = enum { + /// Establish the benchmark loop overhead. + noop, + + /// Encode one complete snapshot per loop. + encode, + + /// Restore one complete snapshot per loop, including its teardown. + decode, + + /// Restore one renderable READY prefix per loop, including teardown. + @"decode-ready", + + /// Print encoded sizes by record tag. Not a timing benchmark. + report, +}; + +pub fn create( + alloc: Allocator, + opts: Options, +) !*TerminalSnapshot { + const ptr = try alloc.create(TerminalSnapshot); + errdefer alloc.destroy(ptr); + ptr.* = .{ + .alloc = alloc, + .opts = opts, + .encoded = .init(alloc), + }; + return ptr; +} + +pub fn destroy(self: *TerminalSnapshot, alloc: Allocator) void { + if (self.terminal) |*t| t.deinit(self.alloc); + self.encoded.deinit(); + alloc.destroy(self); +} + +pub fn benchmark(self: *TerminalSnapshot) Benchmark { + return .init(self, .{ + .stepFn = switch (self.opts.mode) { + .noop => stepNoop, + .encode => stepEncode, + .decode => stepDecode, + .@"decode-ready" => stepDecodeReady, + .report => stepReport, + }, + .setupFn = setup, + .teardownFn = teardown, + }); +} + +/// Build the terminal state every mode shares and, for decode mode, the +/// encoded snapshot it restores. All of this is outside the timed region. +fn setup(ptr: *anyopaque) Benchmark.Error!void { + const self: *TerminalSnapshot = @ptrCast(@alignCast(ptr)); + self.setupImpl() catch |err| { + log.warn("failed to prepare snapshot benchmark err={}", .{err}); + return error.BenchmarkFailed; + }; +} + +fn setupImpl(self: *TerminalSnapshot) !void { + if (self.terminal) |*t| t.deinit(self.alloc); + self.terminal = null; + self.terminal = try Terminal.init(global.io(), self.alloc, .{ + .cols = self.opts.@"terminal-cols", + .rows = self.opts.@"terminal-rows", + .max_scrollback_bytes = null, + .max_scrollback_lines = null, + }); + const terminal = &self.terminal.?; + + // Feed the input corpus through the standard VT stream. + if (try options.dataFile(self.opts.data)) |data_f| { + defer data_f.close(global.io()); + + var stream = terminal.vtStream(); + defer stream.deinit(); + + var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined; + var f_reader = data_f.reader(global.io(), &read_buf); + const r = &f_reader.interface; + + var buf: [4096]u8 = undefined; + while (true) { + const n = try r.readSliceShort(&buf); + if (n == 0) break; // EOF reached + stream.nextSlice(buf[0..n]); + } + } + + // Decode restores the same bytes every step. Encode mode also uses + // this buffer, in which case this is its one-time growth. + self.encoded.shrinkRetainingCapacity(0); + try snapshot.encode( + self.alloc, + &self.encoded.writer, + terminal, + .{ .continuation = .ground }, + ); +} + +fn teardown(ptr: *anyopaque) void { + const self: *TerminalSnapshot = @ptrCast(@alignCast(ptr)); + if (self.terminal) |*t| t.deinit(self.alloc); + self.terminal = null; + self.encoded.shrinkRetainingCapacity(0); +} + +fn stepNoop(ptr: *anyopaque) Benchmark.Error!void { + const self: *TerminalSnapshot = @ptrCast(@alignCast(ptr)); + for (0..self.opts.loops) |_| { + std.mem.doNotOptimizeAway(self.encoded.written()); + } +} + +fn stepEncode(ptr: *anyopaque) Benchmark.Error!void { + const self: *TerminalSnapshot = @ptrCast(@alignCast(ptr)); + const terminal = &self.terminal.?; + for (0..self.opts.loops) |_| { + self.encoded.shrinkRetainingCapacity(0); + snapshot.encode( + self.alloc, + &self.encoded.writer, + terminal, + .{ .continuation = .ground }, + ) catch |err| { + log.warn("snapshot encoding failed err={}", .{err}); + return error.BenchmarkFailed; + }; + std.mem.doNotOptimizeAway(self.encoded.written()); + } +} + +fn stepDecode(ptr: *anyopaque) Benchmark.Error!void { + const self: *TerminalSnapshot = @ptrCast(@alignCast(ptr)); + const bytes = self.encoded.written(); + for (0..self.opts.loops) |_| { + var reader: std.Io.Reader = .fixed(bytes); + var decoded = snapshot.decode( + self.alloc, + global.io(), + &reader, + .{ .max_continuation_bytes = 1024 * 1024 }, + ) catch |err| { + log.warn("snapshot decoding failed err={}", .{err}); + return error.BenchmarkFailed; + }; + std.mem.doNotOptimizeAway(&decoded); + decoded.deinit(self.alloc); + } +} + +/// The renderable-prefix half of decode: everything through READY, leaving +/// all history unread. The gap between this and `stepDecode` is the latency +/// class the incremental decoder removes from time-to-interactive. +fn stepDecodeReady(ptr: *anyopaque) Benchmark.Error!void { + const self: *TerminalSnapshot = @ptrCast(@alignCast(ptr)); + const bytes = self.encoded.written(); + for (0..self.opts.loops) |_| { + var reader: std.Io.Reader = .fixed(bytes); + var decoder: snapshot.Decoder = .init(&reader); + var decoded = decoder.ready( + self.alloc, + global.io(), + .{ .max_continuation_bytes = 1024 * 1024 }, + ) catch |err| { + log.warn("snapshot READY decoding failed err={}", .{err}); + return error.BenchmarkFailed; + }; + std.mem.doNotOptimizeAway(&decoded); + decoded.deinit(self.alloc); + } +} + +/// Print the encoded size grouped by record tag. This shares the encoder +/// with encode mode but deliberately makes no timing claims. +fn stepReport(ptr: *anyopaque) Benchmark.Error!void { + const self: *TerminalSnapshot = @ptrCast(@alignCast(ptr)); + const bytes = self.encoded.written(); + + var payload_totals = std.enums.EnumArray( + snapshot.record.Tag, + u64, + ).initFill(0); + var record_counts = std.enums.EnumArray( + snapshot.record.Tag, + u64, + ).initFill(0); + var record_total: u64 = 0; + + var offset: usize = snapshot.envelope.encoded_len; + while (offset + snapshot.record.Header.len <= bytes.len) { + const tag_raw = std.mem.readInt(u16, bytes[offset..][0..2], .little); + const payload_len = std.mem.readInt( + u32, + bytes[offset + 2 ..][0..4], + .little, + ); + const tag = std.enums.fromInt(snapshot.record.Tag, tag_raw) orelse { + log.warn("unknown record tag {}", .{tag_raw}); + return error.BenchmarkFailed; + }; + payload_totals.getPtr(tag).* += payload_len; + record_counts.getPtr(tag).* += 1; + record_total += 1; + offset += snapshot.record.Header.len + payload_len; + } + + var it = payload_totals.iterator(); + while (it.next()) |entry| { + std.debug.print("terminal-snapshot tag={s} records={d} payload={d}\n", .{ + @tagName(entry.key), + record_counts.get(entry.key), + entry.value.*, + }); + } + std.debug.print( + "terminal-snapshot total records={d} framing={d} encoded={d}\n", + .{ + record_total, + record_total * snapshot.record.Header.len + + snapshot.envelope.encoded_len, + bytes.len, + }, + ); +} + +test TerminalSnapshot { + const testing = std.testing; + const impl: *TerminalSnapshot = try .create(testing.allocator, .{}); + defer impl.destroy(testing.allocator); + + const bench = impl.benchmark(); + _ = try bench.run(.once); +} + +test "TerminalSnapshot decode round trip" { + const testing = std.testing; + const impl: *TerminalSnapshot = try .create(testing.allocator, .{ + .mode = .decode, + .@"terminal-rows" = 4, + .@"terminal-cols" = 8, + }); + defer impl.destroy(testing.allocator); + + const bench = impl.benchmark(); + _ = try bench.run(.once); +} + +test "TerminalSnapshot decode READY prefix" { + const testing = std.testing; + const impl: *TerminalSnapshot = try .create(testing.allocator, .{ + .mode = .@"decode-ready", + .@"terminal-rows" = 4, + .@"terminal-cols" = 8, + }); + defer impl.destroy(testing.allocator); + + const bench = impl.benchmark(); + _ = try bench.run(.once); +} diff --git a/vendor/libghostty-vt/src/benchmark/TerminalStream.zig b/vendor/libghostty-vt/src/benchmark/TerminalStream.zig index a5302005..a16e36e0 100644 --- a/vendor/libghostty-vt/src/benchmark/TerminalStream.zig +++ b/vendor/libghostty-vt/src/benchmark/TerminalStream.zig @@ -19,6 +19,7 @@ const Benchmark = @import("Benchmark.zig"); const options = @import("options.zig"); const Terminal = terminalpkg.Terminal; const Stream = terminalpkg.TerminalStream; +const global = @import("../global.zig"); const log = std.log.scoped(.@"terminal-stream-bench"); @@ -27,7 +28,7 @@ terminal: Terminal, stream: Stream, /// The file, opened in the setup function. -data_f: ?std.fs.File = null, +data_f: ?std.Io.File = null, pub const Options = struct { /// The size of the terminal. This affects benchmarking when @@ -36,7 +37,13 @@ pub const Options = struct { @"terminal-rows": u16 = 80, @"terminal-cols": u16 = 120, - /// The data to read as a filepath. If this is "-" then + /// Enable opt-in continuation tracking on the stream. + @"continuation-enabled": bool = false, + + /// Maximum continuation suffix retained when tracking is enabled. + @"continuation-max-bytes": usize = 1024 * 1024, + + /// Pre-generated data from ghostty-gen. If this is "-" then /// we will read stdin. If this is unset, then we will /// do nothing (benchmark is a noop). It'd be more unixy to /// use stdin by default but I find that a hanging CLI command @@ -54,13 +61,21 @@ pub fn create( ptr.* = .{ .opts = opts, - .terminal = try .init(alloc, .{ + .terminal = try .init(global.io(), alloc, .{ .rows = opts.@"terminal-rows", .cols = opts.@"terminal-cols", }), .stream = undefined, }; - ptr.stream = .initAlloc(alloc, .init(&ptr.terminal)); + errdefer ptr.terminal.deinit(alloc); + ptr.stream = .init(.{ + .allocator = alloc, + .handler = .init(&ptr.terminal), + .continuation_max_bytes = if (opts.@"continuation-enabled") + opts.@"continuation-max-bytes" + else + null, + }); return ptr; } @@ -85,8 +100,8 @@ fn setup(ptr: *anyopaque) Benchmark.Error!void { // Always reset our terminal state self.terminal.fullReset(); - // Open our data file to prepare for reading. We can do more - // validation here eventually. + // Open our data file to prepare for reading. We can do more validation + // here eventually. assert(self.data_f == null); self.data_f = options.dataFile(self.opts.data) catch |err| { log.warn("error opening data file err={}", .{err}); @@ -97,7 +112,7 @@ fn setup(ptr: *anyopaque) Benchmark.Error!void { fn teardown(ptr: *anyopaque) void { const self: *TerminalStream = @ptrCast(@alignCast(ptr)); if (self.data_f) |f| { - f.close(); + f.close(global.io()); self.data_f = null; } } @@ -112,8 +127,10 @@ fn step(ptr: *anyopaque) Benchmark.Error!void { // aren't currently IO bound. const f = self.data_f orelse return; - var read_buf: [64 * 1024]u8 align(std.atomic.cache_line) = undefined; - var f_reader = f.reader(&read_buf); + // Unbuffered: readSliceShort below reads directly into `buf`, + // avoiding a per-chunk memcpy through an intermediate reader + // buffer that would pollute the measurement. + var f_reader = f.reader(global.io(), &.{}); const r = &f_reader.interface; // This buffer size matches the read buffer size used by the @@ -139,4 +156,12 @@ test TerminalStream { const bench = impl.benchmark(); _ = try bench.run(.once); + + const tracked: *TerminalStream = try .create(alloc, .{ + .@"continuation-enabled" = true, + }); + defer tracked.destroy(alloc); + + const tracked_bench = tracked.benchmark(); + _ = try tracked_bench.run(.once); } diff --git a/vendor/libghostty-vt/src/benchmark/cli.zig b/vendor/libghostty-vt/src/benchmark/cli.zig index dc4d7b8f..f76dfc4b 100644 --- a/vendor/libghostty-vt/src/benchmark/cli.zig +++ b/vendor/libghostty-vt/src/benchmark/cli.zig @@ -1,6 +1,7 @@ const std = @import("std"); const Allocator = std.mem.Allocator; const cli = @import("../cli.zig"); +const global = @import("../global.zig"); /// The available actions for the CLI. This is the list of available /// benchmarks. View docs for each individual one in the predictably @@ -13,7 +14,10 @@ pub const Action = enum { @"page-compression", @"scrollback-compression", @"screen-clone", + @"terminal-formatter", @"terminal-parser", + @"terminal-resize", + @"terminal-snapshot", @"terminal-stream", @"is-symbol", @"osc-parser", @@ -36,7 +40,10 @@ pub const Action = enum { .@"terminal-stream" => @import("TerminalStream.zig"), .@"codepoint-width" => @import("CodepointWidth.zig"), .@"grapheme-break" => @import("GraphemeBreak.zig"), + .@"terminal-formatter" => @import("TerminalFormatter.zig"), .@"terminal-parser" => @import("TerminalParser.zig"), + .@"terminal-resize" => @import("TerminalResize.zig"), + .@"terminal-snapshot" => @import("TerminalSnapshot.zig"), .@"is-symbol" => @import("IsSymbol.zig"), .@"osc-parser" => @import("OscParser.zig"), }; @@ -44,19 +51,20 @@ pub const Action = enum { }; /// An entrypoint for the benchmark CLI. -pub fn main() !void { +pub fn main(minimal: std.process.Init.Minimal) !void { + try global.init(.{ .tool = minimal }); const alloc = std.heap.c_allocator; - const action_ = try cli.action.detectArgs(Action, alloc); + const action_ = try cli.action.detectArgs(Action, alloc, minimal.args); const action = action_ orelse return error.NoAction; - try mainAction(alloc, action, .cli); + try mainAction(alloc, action, .{ .cli = minimal.args }); } /// Arguments that can be passed to the benchmark. pub const Args = union(enum) { /// The arguments passed to the CLI via argc/argv. - cli, + cli: std.process.Args, - /// Simple string arguments, parsed via std.process.ArgIteratorGeneral. + /// Simple string arguments, parsed via ArgIteratorGeneral. string: []const u8, }; @@ -83,13 +91,13 @@ fn mainActionImpl( var opts: Options = .{}; defer if (@hasDecl(Options, "deinit")) opts.deinit(); switch (args) { - .cli => { - var iter = try cli.args.argsIterator(alloc); + .cli => |process_args| { + var iter = try cli.args.argsIterator(alloc, process_args); defer iter.deinit(); try cli.args.parse(Options, alloc, &opts, &iter); }, .string => |str| { - var iter = try std.process.ArgIteratorGeneral(.{}).init( + var iter = try std.process.Args.IteratorGeneral(.{}).init( alloc, str, ); diff --git a/vendor/libghostty-vt/src/benchmark/main.zig b/vendor/libghostty-vt/src/benchmark/main.zig index f22891c7..2c1f31f5 100644 --- a/vendor/libghostty-vt/src/benchmark/main.zig +++ b/vendor/libghostty-vt/src/benchmark/main.zig @@ -6,7 +6,10 @@ pub const CodepointWidth = @import("CodepointWidth.zig"); pub const GraphemeBreak = @import("GraphemeBreak.zig"); pub const HyperlinkMap = @import("HyperlinkMap.zig"); pub const ScreenClone = @import("ScreenClone.zig"); +pub const TerminalFormatter = @import("TerminalFormatter.zig"); pub const TerminalParser = @import("TerminalParser.zig"); +pub const TerminalResize = @import("TerminalResize.zig"); +pub const TerminalSnapshot = @import("TerminalSnapshot.zig"); pub const IsSymbol = @import("IsSymbol.zig"); pub const PageCompression = @import("PageCompression.zig"); pub const ScrollbackCompression = @import("ScrollbackCompression.zig"); diff --git a/vendor/libghostty-vt/src/benchmark/options.zig b/vendor/libghostty-vt/src/benchmark/options.zig index 049e80f4..9322818d 100644 --- a/vendor/libghostty-vt/src/benchmark/options.zig +++ b/vendor/libghostty-vt/src/benchmark/options.zig @@ -1,20 +1,21 @@ //! This file contains helpers for CLI options. const std = @import("std"); +const global = @import("../global.zig"); /// Returns the data file for the given path in a way that is consistent /// across our CLI. If the path is not set then no file is returned. /// If the path is "-", then we will return stdin. If the path is /// a file then we will open and return the handle. -pub fn dataFile(path_: ?[]const u8) !?std.fs.File { +pub fn dataFile(path_: ?[]const u8) !?std.Io.File { const path = path_ orelse return null; // Stdin if (std.mem.eql(u8, path, "-")) return .stdin(); // Normal file - const file = try std.fs.cwd().openFile(path, .{}); - errdefer file.close(); + const file = try std.Io.Dir.cwd().openFile(global.io(), path, .{}); + errdefer file.close(global.io()); return file; } diff --git a/vendor/libghostty-vt/src/build/Config.zig b/vendor/libghostty-vt/src/build/Config.zig index 0a994731..71fa4d28 100644 --- a/vendor/libghostty-vt/src/build/Config.zig +++ b/vendor/libghostty-vt/src/build/Config.zig @@ -43,7 +43,7 @@ lib_version: std.SemanticVersion = .{ .major = 0, .minor = 0, .patch = 0 }, /// Binary properties pie: bool = false, strip: bool = false, -patch_rpath: ?[]const u8 = null, +patchelf: ?PatchElf = null, /// Artifacts flatpak: bool = false, @@ -62,17 +62,30 @@ emit_xcframework: bool = false, emit_webdata: bool = false, emit_unicode_table_gen: bool = false, +/// Feature gates for libghostty-vt artifacts (-Dvt-features). The full +/// Ghostty application ignores this and always enables everything. +vt_features: TerminalBuildOptions.Features = .{}, + /// True when Ghostty is being built as a dependency of another project /// rather than as the root project. is_dep: bool = false, /// Environmental properties -env: std.process.EnvMap, +env: *const std.process.Environ.Map, pub fn init(b: *std.Build, appVersion: []const u8, libVersion: []const u8) !Config { // Setup our standard Zig target and optimize options, i.e. // `-Doptimize` and `-Dtarget`. const optimize = b.standardOptimizeOption(.{}); + + // Default dependency builds to libghostty-vt-only mode. Consumers can + // still explicitly disable this to request the full Ghostty build. + const is_dep = b.dep_prefix.len > 0; + const emit_lib_vt = b.option( + bool, + "emit-lib-vt", + "Set defaults for a libghostty-vt-only build (disables xcframework, macOS app, and docs).", + ) orelse is_dep; const target = target: { var result = b.standardTargetOptions(.{}); @@ -97,19 +110,48 @@ pub fn init(b: *std.Build, appVersion: []const u8, libVersion: []const u8) !Conf result = b.resolveTargetQuery(query); } + // On wasm, default to enabling the simd128 feature. Every + // browser engine has supported it since 2023 or earlier + // (Chrome 91, Firefox 89, Safari 16.4) and it is a large + // performance win for the terminal hot paths (50%+ on + // print-heavy VT streams). Only apply when no explicit CPU + // was requested; targets for exotic non-browser runtimes + // can opt out with `-Dcpu=generic`. + if (result.result.cpu.arch.isWasm() and + result.query.cpu_model == .determined_by_arch_os and + result.query.cpu_features_add.isEmpty() and + result.query.cpu_features_sub.isEmpty()) + { + var query = result.query; + query.cpu_features_add.addFeature( + @intFromEnum(std.Target.wasm.Feature.simd128), + ); + result = b.resolveTargetQuery(query); + } + + // The full Ghostty build no longer supports iOS; Fail early + // with a clear message rather than partway through the build. + if (result.result.os.tag == .ios and !emit_lib_vt) { + std.log.err( + "iOS is not a supported target for the full Ghostty build; " ++ + "only libghostty-vt supports iOS (-Demit-lib-vt)", + .{}, + ); + return error.UnsupportedTarget; + } + // If we have no minimum OS version, we set the default based on // our tag. Not all tags have a minimum so this may be null. if (result.query.os_version_min == null) { - result.query.os_version_min = osVersionMin(result.result.os.tag); + result.query.os_version_min = if (emit_lib_vt) + osVersionMinLibVt(result.result.os.tag) + else + osVersionMin(result.result.os.tag); } break :target result; }; - // Detect if Ghostty is a dependency of another project. - // dep_prefix is non-empty when this build is running as a dependency. - const is_dep = b.dep_prefix.len > 0; - // 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. @@ -124,10 +166,10 @@ pub fn init(b: *std.Build, appVersion: []const u8, libVersion: []const u8) !Conf // defaults. const gtk_targets = gtk.targets(b); - // We use env vars throughout the build so we grab them immediately here. - var env = try std.process.getEnvMap(b.allocator); - errdefer env.deinit(); + // Grab the environment from build state + const env = &b.graph.environ_map; + // We use env vars throughout the build so we grab them immediately here. var config: Config = .{ .optimize = optimize, .target = target, @@ -198,9 +240,10 @@ pub fn init(b: *std.Build, appVersion: []const u8, libVersion: []const u8) !Conf "simd", "Build with SIMD-accelerated code paths. Results in significant performance improvements.", ) orelse simd: { - // We can't build our SIMD dependencies for Wasm. Note that we may - // still use SIMD features in the Wasm-builds. - if (target.result.cpu.arch.isWasm()) break :simd false; + // We can't build our SIMD dependencies for Wasm or freestanding + // targets. Note that we may still use SIMD features in the Wasm-builds. + if (target.result.cpu.arch.isWasm() or + target.result.os.tag == .freestanding) break :simd false; break :simd true; }; @@ -312,24 +355,55 @@ pub fn init(b: *std.Build, appVersion: []const u8, libVersion: []const u8) !Conf //--------------------------------------------------------------- // Binary Properties - // On NixOS, the built binary from `zig build` needs to patch the rpath - // into the built binary for it to be portable across the NixOS system - // it was built for. We default this to true if we can detect we're in - // a Nix shell and have LD_LIBRARY_PATH set. - config.patch_rpath = b.option( + // On NixOS, the built binary from `zig build` needs to patch the interp + // and rpath into the built binary for it to be portable across the NixOS + // system it was built for. We default this to true if we can detect we're + // in a Nix shell. + // + // Note that this option is only available on Linux and if building + // natively. + // + // NOTE: Zig does now have an option to pass in the dynamic linker + // (-Ddynamic-linker), but it does seem to have some issues in 0.16.0 that + // may be fixed in 0.17.0. We may want to revisit this afterwards; although + // I'm not too sure if that helps to clean up rpath, this may just be the + // better option. See https://codeberg.org/ziglang/zig/issues/31760. + if (b.option( + []const u8, + "patch-interp", + "Inject the supplied path as the dynamic linker in the built binary. " ++ + "Under Nix, this defaults to the dynamic linker found in PATH.", + )) |interp| { + PatchElf.setInterp(&config, interp); + } else patch_interp: { + if (!(target.result.os.tag == .linux) or !target.query.isNativeCpu()) break :patch_interp; + if (env.get("IN_NIX_SHELL") == null) break :patch_interp; + + if (b.findProgram(&.{"ld.so"}, &.{})) |ld_so| { + PatchElf.setInterp( + &config, + std.Io.Dir.realPathFileAbsoluteAlloc(b.graph.io, ld_so, b.allocator) catch break :patch_interp, + ); + } else |_| {} + } + + if (b.option( []const u8, "patch-rpath", - "Inject the LD_LIBRARY_PATH as the rpath in the built binary. " ++ - "This defaults to LD_LIBRARY_PATH if we're in a Nix shell environment on NixOS.", - ) orelse patch_rpath: { - // We only do the patching if we're targeting our own CPU and its Linux. - if (!(target.result.os.tag == .linux) or !target.query.isNativeCpu()) break :patch_rpath null; - - // If we're in a nix shell we default to doing this. - // Note: we purposely never deinit envmap because we leak the strings - if (env.get("IN_NIX_SHELL") == null) break :patch_rpath null; - break :patch_rpath env.get("LD_LIBRARY_PATH"); - }; + "Inject the supplied colon-delimited search path as the rpath in the built binary. " ++ + "This defaults to LD_LIBRARY_PATH if we're in a Nix shell environment.", + )) |rpath| { + PatchElf.setRpath(&config, rpath); + } else patch_rpath: { + if (!(target.result.os.tag == .linux) or !target.query.isNativeCpu()) break :patch_rpath; + if (env.get("IN_NIX_SHELL") == null) break :patch_rpath; + + if (env.get("LD_LIBRARY_PATH")) |ld_library_path| { + if (ld_library_path.len > 0) { + PatchElf.setRpath(&config, ld_library_path); + } + } + } config.pie = b.option( bool, @@ -350,11 +424,31 @@ pub fn init(b: *std.Build, appVersion: []const u8, libVersion: []const u8) !Conf //--------------------------------------------------------------- // Artifacts to Emit - config.emit_lib_vt = b.option( - bool, - "emit-lib-vt", - "Set defaults for a libghostty-vt-only build (disables xcframework, macOS app, and docs).", - ) orelse false; + config.emit_lib_vt = emit_lib_vt; + + config.vt_features = features: { + const list = b.option( + []const u8, + "vt-features", + "Comma-separated libghostty-vt feature modifications applied " ++ + "to the default all-enabled set, -Dcpu style: `+feature` " ++ + "or `feature` enables, `-feature` disables, and `all` " ++ + "means every feature (e.g. `-all,+render-state` for a " ++ + "render-only build). Only applies to lib artifacts.", + ) orelse break :features .{}; + break :features TerminalBuildOptions.Features.parse(list) catch { + var valid: std.ArrayList(u8) = .empty; + inline for (@typeInfo(TerminalBuildOptions.Features).@"struct".fields) |field| { + if (valid.items.len > 0) try valid.appendSlice(b.allocator, ", "); + try valid.appendSlice(b.allocator, field.name); + } + std.log.err( + "-Dvt-features={s} contains an unknown feature. Valid features: all, {s}", + .{ list, valid.items }, + ); + return error.UnknownVtFeature; + }; + }; config.emit_exe = b.option( bool, @@ -401,7 +495,7 @@ pub fn init(b: *std.Build, appVersion: []const u8, libVersion: []const u8) !Conf if (system_package) break :emit_docs true; // We only default to true if we can find pandoc. - const path = expandPath(b.allocator, "pandoc") catch + const path = expandPath(b.graph.io, b.allocator, &b.graph.environ_map, "pandoc") catch break :emit_docs false; defer if (path) |p| b.allocator.free(p); break :emit_docs path != null; @@ -450,7 +544,7 @@ pub fn init(b: *std.Build, appVersion: []const u8, libVersion: []const u8) !Conf if (config.emit_lib_vt) { // In lib-vt mode default to whether xcodebuild is available, // since xcodebuild is required to produce the XCFramework. - const path = expandPath(b.allocator, "xcodebuild") catch + const path = expandPath(b.graph.io, b.allocator, &b.graph.environ_map, "xcodebuild") catch break :emit_xcfw false; defer if (path) |p| b.allocator.free(p); break :emit_xcfw path != null; @@ -522,6 +616,45 @@ pub fn init(b: *std.Build, appVersion: []const u8, libVersion: []const u8) !Conf return config; } +const PatchElf = struct { + interp: ?[]const u8 = null, + rpath: ?[]const u8 = null, + + fn setInterp(config: *Config, interp: []const u8) void { + if (config.patchelf) |*patchelf| { + patchelf.interp = interp; + return; + } + + config.patchelf = .{ .interp = interp }; + } + + fn setRpath(config: *Config, rpath: []const u8) void { + if (config.patchelf) |*patchelf| { + patchelf.rpath = rpath; + return; + } + + config.patchelf = .{ .rpath = rpath }; + } +}; + +/// Add a patchelf step for the supplied `artifact`, depending on the supplied +/// `step`, if `patchelf` is present in the config. +pub fn addPatchElf(self: *const Config, artifact: *std.Build.Step.Compile, step: *std.Build.Step) void { + const b = artifact.step.owner; + std.debug.assert(b == step.owner); + + if (self.patchelf) |patchelf| { + const run = std.Build.Step.Run.create(b, "patchelf"); + run.addArgs(&.{"patchelf"}); + if (patchelf.interp) |interp| run.addArgs(&.{ "--set-interpreter", interp }); + if (patchelf.rpath) |rpath| run.addArgs(&.{ "--set-rpath", rpath }); + run.addArtifactArg(artifact); + step.dependOn(&run.step); + } +} + /// Configure the build options with our values. pub fn addOptions(self: *const Config, step: *std.Build.Step.Options) !void { // We need to break these down individual because addOption doesn't @@ -570,17 +703,27 @@ pub fn addOptions(self: *const Config, step: *std.Build.Step.Options) !void { /// Returns the build options for the terminal module. This assumes a /// Ghostty executable being built. Callers should modify this as needed. -pub fn terminalOptions(self: *const Config, artifact: TerminalBuildOptions.Artifact) TerminalBuildOptions { +pub fn terminalOptions( + self: *const Config, + artifact: TerminalBuildOptions.Artifact, + optimize: std.builtin.OptimizeMode, +) TerminalBuildOptions { return .{ .artifact = artifact, .simd = self.simd, .oniguruma = true, .c_abi = false, + // The application requires every feature; only lib artifacts + // may trim them. + .features = switch (artifact) { + .ghostty => .{}, + .lib => self.vt_features, + }, .version = switch (artifact) { .ghostty => self.version, .lib => self.lib_version, }, - .slow_runtime_safety = switch (self.optimize) { + .slow_runtime_safety = switch (optimize) { .Debug => true, .ReleaseSafe, .ReleaseSmall, @@ -591,7 +734,7 @@ pub fn terminalOptions(self: *const Config, artifact: TerminalBuildOptions.Artif } /// Returns a baseline CPU target retaining all the other CPU configs. -pub fn baselineTarget(self: *const Config) std.Build.ResolvedTarget { +pub fn baselineTarget(self: *const Config, io: std.Io) std.Build.ResolvedTarget { // Set our cpu model as baseline. There may need to be other modifications // we need to make such as resetting CPU features but for now this works. var q = self.target.query; @@ -601,7 +744,7 @@ pub fn baselineTarget(self: *const Config) std.Build.ResolvedTarget { // handle the native case. return .{ .query = q, - .result = std.zig.system.resolveTargetQuery(q) catch + .result = std.zig.system.resolveTargetQuery(io, q) catch @panic("unable to resolve baseline query"), }; } @@ -630,6 +773,17 @@ pub fn fromOptions() Config { }; } +/// Whether release artifacts should omit frame pointers. +/// +/// Stripped release builds omit them in general, but we always keep frame +/// pointers on Apple platforms: the Apple arm64 ABI expects x29 to +/// be a valid frame pointer and Apple's profiling and crash reporting +/// tools rely on it for backtraces. +pub fn omitFramePointer(self: *const Config) bool { + if (self.target.result.os.tag.isDarwin()) return false; + return self.strip; +} + /// Returns the minimum OS version for the given OS tag. This shouldn't /// be used generally, it should only be used for Darwin-based OS currently. pub fn osVersionMin(tag: std.Target.Os.Tag) ?std.Target.Query.OsVersion { @@ -642,19 +796,22 @@ pub fn osVersionMin(tag: std.Target.Os.Tag) ?std.Target.Query.OsVersion { .patch = 0, } }, - // iOS 17 picked arbitrarily - .ios => .{ .semver = .{ - .major = 17, - .minor = 0, - .patch = 0, - } }, - // This should never happen currently. If we add a new target then // we should add a new case here. else => null, }; } +/// Returns the minimum OS version for lib-vt build. +/// +/// This should only be used for Darwin targets. +pub fn osVersionMinLibVt(tag: std.Target.Os.Tag) ?std.Target.Query.OsVersion { + // lib-vt is the only thing we still build for iOS, so its deployment + // target lives here rather than in osVersionMin. + if (tag == .ios) return .{ .semver = .{ .major = 13, .minor = 0, .patch = 0 } }; + return osVersionMin(tag); +} + // Returns a ResolvedTarget for a mac with a `target.result.cpu.model.name` of `generic`. // `b.standardTargetOptions()` returns a more specific cpu like `apple_a15`. // diff --git a/vendor/libghostty-vt/src/build/GhosttyBench.zig b/vendor/libghostty-vt/src/build/GhosttyBench.zig index 27dda880..fbf8029d 100644 --- a/vendor/libghostty-vt/src/build/GhosttyBench.zig +++ b/vendor/libghostty-vt/src/build/GhosttyBench.zig @@ -23,9 +23,9 @@ pub fn init( // We always want our datagen to be fast because it // takes awhile to run. .optimize = .ReleaseFast, + .link_libc = true, }), }); - exe.linkLibC(); _ = try deps.add(exe); try steps.append(b.allocator, exe); } @@ -39,9 +39,9 @@ pub fn init( .target = deps.config.target, // We always want our benchmarks to be in release mode. .optimize = .ReleaseFast, + .link_libc = true, }), }); - exe.linkLibC(); _ = try deps.add(exe); try steps.append(b.allocator, exe); } diff --git a/vendor/libghostty-vt/src/build/GhosttyDist.zig b/vendor/libghostty-vt/src/build/GhosttyDist.zig index 448047f4..fe8b863f 100644 --- a/vendor/libghostty-vt/src/build/GhosttyDist.zig +++ b/vendor/libghostty-vt/src/build/GhosttyDist.zig @@ -237,11 +237,11 @@ pub const Resource = struct { /// Returns true if the dist path exists at build time. pub fn exists(self: *const Resource, b: *std.Build) bool { - if (b.build_root.handle.access(self.dist, .{})) { + if (b.build_root.handle.access(b.graph.io, self.dist, .{})) { // If we have a ".git" directory then we're a git checkout // and we never want to use the dist path. This shouldn't happen // so show a warning to the user. - if (b.build_root.handle.access(".git", .{})) { + if (b.build_root.handle.access(b.graph.io, ".git", .{})) { std.log.warn( "dist resource '{s}' should not be in a git checkout", .{self.dist}, diff --git a/vendor/libghostty-vt/src/build/GhosttyDocs.zig b/vendor/libghostty-vt/src/build/GhosttyDocs.zig index cd75fc06..2b207f23 100644 --- a/vendor/libghostty-vt/src/build/GhosttyDocs.zig +++ b/vendor/libghostty-vt/src/build/GhosttyDocs.zig @@ -50,7 +50,7 @@ pub fn init( generate_markdown.root_module.addOptions("build_options", generate_markdown_options); const generate_markdown_step = b.addRunArtifact(generate_markdown); - const markdown_output = generate_markdown_step.captureStdOut(); + const markdown_output = generate_markdown_step.captureStdOut(.{}); try steps.append(b.allocator, &b.addInstallFile( markdown_output, @@ -68,7 +68,7 @@ pub fn init( generate_html.addFileArg(markdown_output); try steps.append(b.allocator, &b.addInstallFile( - generate_html.captureStdOut(), + generate_html.captureStdOut(.{}), "share/ghostty/doc/" ++ manpage.name ++ "." ++ manpage.section ++ ".html", ).step); @@ -83,7 +83,7 @@ pub fn init( generate_manpage.addFileArg(markdown_output); try steps.append(b.allocator, &b.addInstallFile( - generate_manpage.captureStdOut(), + generate_manpage.captureStdOut(.{}), "share/man/man" ++ manpage.section ++ "/" ++ manpage.name ++ "." ++ manpage.section, ).step); } diff --git a/vendor/libghostty-vt/src/build/GhosttyExe.zig b/vendor/libghostty-vt/src/build/GhosttyExe.zig index caa564bf..22f39b7b 100644 --- a/vendor/libghostty-vt/src/build/GhosttyExe.zig +++ b/vendor/libghostty-vt/src/build/GhosttyExe.zig @@ -18,7 +18,7 @@ pub fn init(b: *std.Build, cfg: *const Config, deps: *const SharedDeps) !Ghostty .target = cfg.target, .optimize = cfg.optimize, .strip = cfg.strip, - .omit_frame_pointer = cfg.strip, + .omit_frame_pointer = cfg.omitFramePointer(), .unwind_tables = if (cfg.strip) .none else .sync, }), // Crashes on x86_64 self-hosted on 0.15.1 @@ -36,21 +36,14 @@ pub fn init(b: *std.Build, cfg: *const Config, deps: *const SharedDeps) !Ghostty // Check for possible issues try checkNixShell(exe, cfg); - // Patch our rpath if that option is specified. - if (cfg.patch_rpath) |rpath| { - if (rpath.len > 0) { - const run = std.Build.Step.Run.create(b, "patchelf rpath"); - run.addArgs(&.{ "patchelf", "--set-rpath", rpath }); - run.addArtifactArg(exe); - install_step.step.dependOn(&run.step); - } - } + // Add patchelf step if that option is specified. + cfg.addPatchElf(exe, &install_step.step); // OS-specific switch (cfg.target.result.os.tag) { .windows => { exe.subsystem = .Windows; - exe.addWin32ResourceFile(.{ + exe.root_module.addWin32ResourceFile(.{ .file = b.path("dist/windows/ghostty.rc"), }); }, @@ -85,7 +78,7 @@ fn checkNixShell(exe: *std.Build.Step.Compile, cfg: *const Config) !void { if (!cfg.target.query.isNativeOs()) return; // Verify we're in NixOS - std.fs.accessAbsolute("/etc/NIXOS", .{}) catch return; + std.Io.Dir.accessAbsolute(exe.step.owner.graph.io, "/etc/NIXOS", .{}) catch return; // If we're in a nix shell, not a problem if (cfg.env.get("IN_NIX_SHELL") != null) return; diff --git a/vendor/libghostty-vt/src/build/GhosttyFrameData.zig b/vendor/libghostty-vt/src/build/GhosttyFrameData.zig index 8469759f..be61f029 100644 --- a/vendor/libghostty-vt/src/build/GhosttyFrameData.zig +++ b/vendor/libghostty-vt/src/build/GhosttyFrameData.zig @@ -40,16 +40,16 @@ pub fn distResources(b: *std.Build) struct { .name = "framegen", .root_module = b.createModule(.{ .target = b.graph.host, + .link_libc = true, }), }); - exe.addCSourceFile(.{ + exe.root_module.addCSourceFile(.{ .file = b.path("src/build/framegen/main.c"), .flags = &.{}, }); - exe.linkLibC(); if (b.systemIntegrationOption("zlib", .{})) { - exe.linkSystemLibrary2("zlib", .{ + exe.root_module.linkSystemLibrary("zlib", .{ .preferred_link_mode = .dynamic, .search_strategy = .mode_first, }); @@ -58,7 +58,7 @@ pub fn distResources(b: *std.Build) struct { .target = b.graph.host, .optimize = .ReleaseFast, })) |zlib_dep| { - exe.linkLibrary(zlib_dep.artifact("z")); + exe.root_module.linkLibrary(zlib_dep.artifact("z")); } } diff --git a/vendor/libghostty-vt/src/build/GhosttyI18n.zig b/vendor/libghostty-vt/src/build/GhosttyI18n.zig index 0874676c..eb6ee5ee 100644 --- a/vendor/libghostty-vt/src/build/GhosttyI18n.zig +++ b/vendor/libghostty-vt/src/build/GhosttyI18n.zig @@ -26,7 +26,7 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyI18n { // 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") + std.mem.trimEnd(u8, locale, ".UTF-8") else locale; @@ -34,7 +34,7 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyI18n { msgfmt.addFileArg(b.path("po/" ++ locale ++ ".po")); try steps.append(b.allocator, &b.addInstallFile( - msgfmt.captureStdOut(), + msgfmt.captureStdOut(.{}), std.fmt.comptimePrint( "share/locale/{s}/LC_MESSAGES/{s}.mo", .{ target_locale, domain }, @@ -66,6 +66,7 @@ fn createUpdateStep(b: *std.Build) !*std.Build.Step { "--language=C", // Silence the "unknown extension" errors "--from-code=UTF-8", "--keyword=_", + "--keyword=N_", "--keyword=C_:1c,2", }); @@ -103,14 +104,15 @@ fn createUpdateStep(b: *std.Build) !*std.Build.Step { } var gtk_dir = try b.build_root.handle.openDir( + b.graph.io, "src/apprt/gtk", .{ .iterate = true }, ); - defer gtk_dir.close(); + defer gtk_dir.close(b.graph.io); var walk = try gtk_dir.walk(b.allocator); defer walk.deinit(); - while (try walk.next()) |src| { + while (try walk.next(b.graph.io)) |src| { switch (src.kind) { .file => if (!std.mem.endsWith( u8, @@ -147,6 +149,11 @@ fn createUpdateStep(b: *std.Build) !*std.Build.Step { } } + // For localization of command palette + const command_palette_path = "src/input/command.zig"; + xgettext.addArg(command_palette_path); + xgettext.addFileInput(b.path(command_palette_path)); + // Add support for localizing our `nautilus` integration const xgettext_py = b.addSystemCommand(&.{ "xgettext", @@ -178,15 +185,15 @@ fn createUpdateStep(b: *std.Build) !*std.Build.Step { xgettext_merge.addFileArg(gtk_pot); const usf = b.addUpdateSourceFiles(); usf.addCopyFileToSource( - xgettext_merge.captureStdOut(), + xgettext_merge.captureStdOut(.{}), "po/" ++ domain ++ ".pot", ); inline for (locales) |locale| { const msgmerge = b.addSystemCommand(&.{ "msgmerge", "--quiet", "--no-fuzzy-matching" }); msgmerge.addFileArg(b.path("po/" ++ locale ++ ".po")); - msgmerge.addFileArg(xgettext_merge.captureStdOut()); - usf.addCopyFileToSource(msgmerge.captureStdOut(), "po/" ++ locale ++ ".po"); + msgmerge.addFileArg(xgettext_merge.captureStdOut(.{})); + usf.addCopyFileToSource(msgmerge.captureStdOut(.{}), "po/" ++ locale ++ ".po"); } return &usf.step; diff --git a/vendor/libghostty-vt/src/build/GhosttyLib.zig b/vendor/libghostty-vt/src/build/GhosttyLib.zig index b762da8b..529bb282 100644 --- a/vendor/libghostty-vt/src/build/GhosttyLib.zig +++ b/vendor/libghostty-vt/src/build/GhosttyLib.zig @@ -1,9 +1,11 @@ const GhosttyLib = @This(); const std = @import("std"); +const builtin = @import("builtin"); const RunStep = std.Build.Step.Run; const CombineArchivesStep = @import("CombineArchivesStep.zig"); const Config = @import("Config.zig"); +const LibsystemOverrideStep = @import("LibsystemOverrideStep.zig"); const SharedDeps = @import("SharedDeps.zig"); const LipoStep = @import("LipoStep.zig"); @@ -27,14 +29,14 @@ pub fn initStatic( .target = deps.config.target, .optimize = deps.config.optimize, .strip = deps.config.strip, - .omit_frame_pointer = deps.config.strip, + .omit_frame_pointer = deps.config.omitFramePointer(), .unwind_tables = if (deps.config.strip) .none else .sync, + .link_libc = true, }), // Fails on self-hosted x86_64 on macOS .use_llvm = true, }); - lib.linkLibC(); // These must be bundled since we're compiling into a static lib. // Otherwise, you get undefined symbol errors. @@ -57,9 +59,19 @@ pub fn initStatic( const combined = CombineArchivesStep.create(b, deps.config.target, "ghostty-internal", lib_list.items); combined.step.dependOn(&lib.step); + // On Darwin, prefer libSystem's libc/libm over the bundled + // compiler-rt for consumers of this archive. See + // libsystem_override.sh for details. This is a no-op elsewhere. + const override = LibsystemOverrideStep.create( + b, + deps.config.target, + combined.output, + "libghostty-internal.a", + ); + return .{ - .step = combined.step, - .output = combined.output, + .step = override.step orelse combined.step, + .output = override.output, // Static libraries cannot have dSYMs because they aren't linked. .dsym = null, @@ -72,6 +84,14 @@ pub fn initShared( b: *std.Build, deps: *const SharedDeps, ) !GhosttyLib { + // For dynamic linking, we prefer dynamic linking and to search by + // mode first. Mode first will search all paths for a dynamic library + // before falling back to static. + const dynamic_link_opts: std.Build.Module.LinkSystemLibraryOptions = .{ + .preferred_link_mode = .dynamic, + .search_strategy = .mode_first, + }; + const lib = b.addLibrary(.{ .name = "ghostty", .linkage = .dynamic, @@ -80,7 +100,7 @@ pub fn initShared( .target = deps.config.target, .optimize = deps.config.optimize, .strip = deps.config.strip, - .omit_frame_pointer = deps.config.strip, + .omit_frame_pointer = deps.config.omitFramePointer(), .unwind_tables = if (deps.config.strip) .none else .sync, }), @@ -99,12 +119,12 @@ pub fn initShared( { // The CRT initialization code in msvcrt.lib calls __vcrt_initialize // and __acrt_initialize, which are in the static CRT libraries. - lib.linkSystemLibrary("libvcruntime"); + lib.root_module.linkSystemLibrary("libvcruntime", dynamic_link_opts); // ucrt.lib is in the Windows SDK 'ucrt' dir. Detect the SDK // installation and add the UCRT library path. const arch = deps.config.target.result.cpu.arch; - const sdk = std.zig.WindowsSdk.find(b.allocator, arch) catch null; + const sdk = std.zig.WindowsSdk.find(b.allocator, b.graph.io, arch, &b.graph.environ_map) catch null; if (sdk) |s| { if (s.windows10sdk) |w10| { const arch_str: []const u8 = switch (arch) { @@ -120,11 +140,11 @@ pub fn initShared( ) catch null; if (ucrt_lib_path) |path| { - lib.addLibraryPath(.{ .cwd_relative = path }); + lib.root_module.addLibraryPath(.{ .cwd_relative = path }); } } } - lib.linkSystemLibrary("libucrt"); + lib.root_module.linkSystemLibrary("libucrt", dynamic_link_opts); } // Get our debug symbols diff --git a/vendor/libghostty-vt/src/build/GhosttyLibVt.zig b/vendor/libghostty-vt/src/build/GhosttyLibVt.zig index 65f945bf..690f864c 100644 --- a/vendor/libghostty-vt/src/build/GhosttyLibVt.zig +++ b/vendor/libghostty-vt/src/build/GhosttyLibVt.zig @@ -5,6 +5,7 @@ const builtin = @import("builtin"); const assert = std.debug.assert; const RunStep = std.Build.Step.Run; const CombineArchivesStep = @import("CombineArchivesStep.zig"); +const LibsystemOverrideStep = @import("LibsystemOverrideStep.zig"); const Config = @import("Config.zig"); const GhosttyZig = @import("GhosttyZig.zig"); const LipoStep = @import("LipoStep.zig"); @@ -57,6 +58,12 @@ pub fn initWasm( // There is no entrypoint for this wasm module. exe.entry = .disabled; + // Default Zig stack size in Zig 0.16 is 1MB. Lower to 128 KB since + // this has to be preallocated up front in Wasm linear memory. Peak + // stack under various artificial loads is 17 KB at the time of this + // comment but lets do 128KB to be safe. We can lower later. + exe.stack_size = 128 * 1024; + // Zig's WASM linker doesn't support --growable-table, so the table // is emitted with max == min and can't be grown from JS. Run a // small Zig build tool that patches the binary's table section to @@ -101,6 +108,16 @@ pub fn initShared( b: *std.Build, zig: *const GhosttyZig, ) !GhosttyLibVt { + const target = zig.vt.resolved_target.?; + + // Prefer Apple's linker for final Darwin dylibs when its toolchain is + // available. Besides producing the platform load commands expected by + // Apple distribution, this lets the static archive's libSystem symbol + // override take effect. Cross-compiled macOS dylibs continue through Zig. + if (@import("apple_sdk").nativeLink.available(target)) { + return initLibApple(b, zig); + } + return initLib(b, zig, .dynamic); } @@ -172,16 +189,21 @@ pub fn initStaticAppleUniversal( const target_query: std.Target.Query = .{ .cpu_arch = .aarch64, .os_tag = p.os_tag, - .os_version_min = Config.osVersionMin(p.os_tag), + .os_version_min = Config.osVersionMinLibVt(p.os_tag), }; - if (detectAppleSDK(b.resolveTargetQuery(target_query).result)) { + if (detectAppleSDK( + b.graph.io, + b.allocator, + &b.graph.environ_map, + b.resolveTargetQuery(target_query).result, + )) { const dev_zig = try zig.retarget(b, cfg, deps, b.resolveTargetQuery(target_query)); result.put(p.device, try initStatic(b, &dev_zig)); const sim_zig = try zig.retarget(b, cfg, deps, b.resolveTargetQuery(.{ .cpu_arch = .aarch64, .os_tag = p.os_tag, - .os_version_min = Config.osVersionMin(p.os_tag), + .os_version_min = Config.osVersionMinLibVt(p.os_tag), .abi = .simulator, .cpu_model = .{ .explicit = &std.Target.aarch64.cpu.apple_a17 }, })); @@ -224,13 +246,28 @@ fn initLib( // Enable PIC so the static library can be linked into PIE // executables, which is the default on most Linux distributions. - lib.root_module.pic = true; + // Native freestanding targets don't have a dynamic loader and some, + // such as Xtensa, don't support PIC relocations. + lib.root_module.pic = target.result.os.tag != .freestanding; } if (target.result.os.tag == .windows) { // Zig's ubsan emits /exclude-symbols linker directives that // are incompatible with the MSVC linker (LNK4229). lib.bundle_ubsan_rt = false; + + if (kind == .static) { + if (target.result.abi == .msvc) { + // Zig's compiler runtime doesn't provide MSVC's security + // cookie symbols when libc is linked. Disable stack-protector + // generation so static consumers don't need BufferOverflowU. + lib.root_module.stack_protector = false; + } + + // The Zig standard library uses NT and kernel32 symbols. + lib.root_module.linkSystemLibrary("ntdll", .{}); + lib.root_module.linkSystemLibrary("kernel32", .{}); + } } if (lib.rootModuleTarget().abi.isAndroid()) { @@ -290,28 +327,171 @@ fn initLib( const combined = CombineArchivesStep.create(b, target, "ghostty-vt", sources.items); combined.step.dependOn(&lib.step); + // On Darwin, prefer libSystem's libc/libm over the bundled + // compiler-rt for consumers of this archive. See GhosttyLib + // and libsystem_override.sh for details. + const override = LibsystemOverrideStep.create( + b, + target, + combined.output, + "libghostty-vt-fat.a", + ); + return .{ - .step = combined.step, + .step = override.step orelse combined.step, .artifact = &b.addInstallArtifact(lib, .{}).step, .kind = kind, - .output = combined.output, + .output = override.output, .dsym = dsymutil, .pkg_config = if (pcs) |v| v.shared else null, .pkg_config_static = if (pcs) |v| v.static else null, }; } + // Same libSystem preference for the plain (no vendored SIMD) + // static archive; a no-op off Darwin. + const override: LibsystemOverrideStep.Result = if (kind == .static) LibsystemOverrideStep.create( + b, + target, + lib.getEmittedBin(), + "libghostty-vt-static.a", + ) else .{ + .step = null, + .output = lib.getEmittedBin(), + }; + return .{ - .step = &lib.step, + .step = override.step orelse &lib.step, .artifact = &b.addInstallArtifact(lib, .{}).step, .kind = kind, - .output = lib.getEmittedBin(), + .output = override.output, .dsym = dsymutil, .pkg_config = if (pcs) |v| v.shared else null, .pkg_config_static = if (pcs) |v| v.static else null, }; } +/// Builds a shared Darwin library with Apple's linker. +fn initLibApple( + b: *std.Build, + zig: *const GhosttyZig, +) !GhosttyLibVt { + // Sorry, this function has a lot of comments because there are + // a lot of flags that aren't obvious. + + const target = zig.vt.resolved_target.?; + assert(target.result.os.tag.isDarwin()); + + // Build the combined archive through the normal static path first. + const static = try initStatic(b, zig); + + // Zig's module-level export metadata does not carry into this separate + // native link, so explicitly expose only libghostty-vt's public C ABI. + const exports = b.addWriteFiles().add( + "libghostty-vt.exports", + "_ghostty_*\n", + ); + + // Match Zig's versioned dylib naming so existing consumers and packaging + // continue to receive the same real name and compatibility symlinks. + const real_name = b.fmt("libghostty-vt.{d}.{d}.{d}.dylib", .{ + zig.version.major, + zig.version.minor, + zig.version.patch, + }); + const soname = b.fmt("libghostty-vt.{d}.dylib", .{zig.version.major}); + + const native_link = try @import("apple_sdk").nativeLink.addCommand( + b, + "link libghostty-vt with Apple ld", + target, + ); + + native_link.addArgs(&.{ + // Emit a Mach-O dynamic library instead of an executable. + "-dynamiclib", + // Pull every object from the combined static archive. + "-Wl,-all_load", + // Remove unreachable private code pulled in by all_load. + "-Wl,-dead_strip", + // Leave room for packaging tools to rewrite install names. + "-Wl,-headerpad_max_install_names", + }); + // Restrict exports to libghostty-vt's public C ABI. + native_link.addPrefixedFileArg("-Wl,-exported_symbols_list,", exports); + // Link the archive produced by the normal static-library path. + native_link.addFileArg(static.output); + native_link.addArgs(&.{ + // Give framework consumers a stable runtime-relative identity. + "-install_name", + "@rpath/libghostty-vt.dylib", + // Record the exact package version for runtime inspection. + "-current_version", + b.fmt("{d}.{d}.{d}", .{ + zig.version.major, + zig.version.minor, + zig.version.patch, + }), + // Preserve compatibility with the initial public ABI. + "-compatibility_version", + "1.0.0", + // Write the fully versioned dylib used by the install symlinks. + "-o", + }); + const output = native_link.addOutputFileArg(real_name); + + // Recreate addInstallArtifact's dylib layout: install the fully versioned + // file, then point the major-version and unversioned names at it. + const artifact_install = b.addInstallFileWithDir( + output, + .lib, + real_name, + ); + const soname_install = b.addSystemCommand(&.{ + "/bin/ln", + "-sf", + real_name, + b.getInstallPath(.lib, soname), + }); + soname_install.step.dependOn(&artifact_install.step); + const unversioned_install = b.addSystemCommand(&.{ + "/bin/ln", + "-sf", + soname, + b.getInstallPath(.lib, "libghostty-vt.dylib"), + }); + unversioned_install.step.dependOn(&soname_install.step); + + // The native link is a Run step rather than a Compile step, so install the + // public headers explicitly instead of relying on addInstallArtifact. + const headers_install = b.addInstallDirectory(.{ + .source_dir = b.path("include/ghostty"), + .install_dir = .header, + .install_subdir = "ghostty", + .include_extensions = &.{".h"}, + }); + unversioned_install.step.dependOn(&headers_install.step); + + // Preserve the debug-symbol output exposed by the normal shared-library + // path for framework and release packaging. + const dsymutil = RunStep.create(b, "dsymutil"); + dsymutil.addArgs(&.{"dsymutil"}); + dsymutil.addFileArg(output); + dsymutil.addArgs(&.{"-o"}); + const dsym = dsymutil.addOutputFileArg("libghostty-vt.dSYM"); + + const pcs = pkgConfigFiles(b, zig, target.result.os.tag); + return .{ + .step = &native_link.step, + .artifact = &unversioned_install.step, + .kind = .shared, + .output = output, + .dsym = dsym, + .pkg_config = pcs.shared, + .pkg_config_static = pcs.static, + }; +} + /// Returns the Libs.private value for the pkg-config file. /// Vendored C++ dependencies are built in no-libcxx mode so consumers /// don't need libc++. System-provided simdutf still requires it. @@ -438,12 +618,21 @@ pub fn xcframework( } /// Returns true if the Apple SDK for the given target is installed. -fn detectAppleSDK(target: std.Target) bool { - _ = std.zig.LibCInstallation.findNative(.{ - .allocator = std.heap.page_allocator, - .target = &target, - .verbose = false, - }) catch return false; +fn detectAppleSDK( + io: std.Io, + alloc: std.mem.Allocator, + environ_map: *const std.process.Environ.Map, + target: std.Target, +) bool { + _ = std.zig.LibCInstallation.findNative( + alloc, + io, + .{ + .environ_map = environ_map, + .target = &target, + .verbose = false, + }, + ) catch return false; return true; } diff --git a/vendor/libghostty-vt/src/build/GhosttyResources.zig b/vendor/libghostty-vt/src/build/GhosttyResources.zig index 6f857655..673c7e56 100644 --- a/vendor/libghostty-vt/src/build/GhosttyResources.zig +++ b/vendor/libghostty-vt/src/build/GhosttyResources.zig @@ -21,9 +21,9 @@ pub fn init(b: *std.Build, cfg: *const Config, deps: *const SharedDeps) !Ghostty .strip = false, .omit_frame_pointer = false, .unwind_tables = .sync, + .link_libc = true, }), }); - build_data_exe.linkLibC(); deps.help_strings.addImport(build_data_exe); @@ -39,7 +39,7 @@ pub fn init(b: *std.Build, cfg: *const Config, deps: *const SharedDeps) !Ghostty const run = b.addRunArtifact(build_data_exe); run.addArg("+terminfo"); const wf = b.addWriteFiles(); - const source = wf.addCopyFile(run.captureStdOut(), "ghostty.terminfo"); + const source = wf.addCopyFile(run.captureStdOut(.{}), "ghostty.terminfo"); if (cfg.emit_terminfo) { const source_install = b.addInstallFile( @@ -63,8 +63,8 @@ pub fn init(b: *std.Build, cfg: *const Config, deps: *const SharedDeps) !Ghostty const run_step = RunStep.create(b, "infotocap"); run_step.addArg("infotocap"); run_step.addFileArg(source); - const out_source = run_step.captureStdOut(); - _ = run_step.captureStdErr(); // so we don't see stderr + const out_source = run_step.captureStdOut(.{}); + _ = run_step.captureStdErr(.{}); // so we don't see stderr const cap_install = b.addInstallFile( out_source, @@ -84,7 +84,7 @@ pub fn init(b: *std.Build, cfg: *const Config, deps: *const SharedDeps) !Ghostty const path = run_step.addOutputFileArg(terminfo_share_dir); run_step.addFileArg(source); - _ = run_step.captureStdErr(); // so we don't see stderr + _ = run_step.captureStdErr(.{}); // so we don't see stderr // Ensure that `share/terminfo` is a directory, otherwise the `cp // -R` will create a file named `share/terminfo` @@ -143,7 +143,7 @@ pub fn init(b: *std.Build, cfg: *const Config, deps: *const SharedDeps) !Ghostty const run = b.addRunArtifact(build_data_exe); run.addArg("+fish"); const wf = b.addWriteFiles(); - _ = wf.addCopyFile(run.captureStdOut(), "ghostty.fish"); + _ = wf.addCopyFile(run.captureStdOut(.{}), "ghostty.fish"); const install_step = b.addInstallDirectory(.{ .source_dir = wf.getDirectory(), @@ -158,7 +158,7 @@ pub fn init(b: *std.Build, cfg: *const Config, deps: *const SharedDeps) !Ghostty const run = b.addRunArtifact(build_data_exe); run.addArg("+zsh"); const wf = b.addWriteFiles(); - _ = wf.addCopyFile(run.captureStdOut(), "_ghostty"); + _ = wf.addCopyFile(run.captureStdOut(.{}), "_ghostty"); const install_step = b.addInstallDirectory(.{ .source_dir = wf.getDirectory(), @@ -173,7 +173,7 @@ pub fn init(b: *std.Build, cfg: *const Config, deps: *const SharedDeps) !Ghostty const run = b.addRunArtifact(build_data_exe); run.addArg("+bash"); const wf = b.addWriteFiles(); - _ = wf.addCopyFile(run.captureStdOut(), "ghostty.bash"); + _ = wf.addCopyFile(run.captureStdOut(.{}), "ghostty.bash"); const install_step = b.addInstallDirectory(.{ .source_dir = wf.getDirectory(), @@ -190,22 +190,22 @@ pub fn init(b: *std.Build, cfg: *const Config, deps: *const SharedDeps) !Ghostty { const run = b.addRunArtifact(build_data_exe); run.addArg("+vim-syntax"); - _ = wf.addCopyFile(run.captureStdOut(), "syntax/ghostty.vim"); + _ = wf.addCopyFile(run.captureStdOut(.{}), "syntax/ghostty.vim"); } { const run = b.addRunArtifact(build_data_exe); run.addArg("+vim-ftdetect"); - _ = wf.addCopyFile(run.captureStdOut(), "ftdetect/ghostty.vim"); + _ = wf.addCopyFile(run.captureStdOut(.{}), "ftdetect/ghostty.vim"); } { const run = b.addRunArtifact(build_data_exe); run.addArg("+vim-ftplugin"); - _ = wf.addCopyFile(run.captureStdOut(), "ftplugin/ghostty.vim"); + _ = wf.addCopyFile(run.captureStdOut(.{}), "ftplugin/ghostty.vim"); } { const run = b.addRunArtifact(build_data_exe); run.addArg("+vim-compiler"); - _ = wf.addCopyFile(run.captureStdOut(), "compiler/ghostty.vim"); + _ = wf.addCopyFile(run.captureStdOut(.{}), "compiler/ghostty.vim"); } const vim_step = b.addInstallDirectory(.{ @@ -233,7 +233,7 @@ pub fn init(b: *std.Build, cfg: *const Config, deps: *const SharedDeps) !Ghostty const run = b.addRunArtifact(build_data_exe); run.addArg("+sublime"); const wf = b.addWriteFiles(); - _ = wf.addCopyFile(run.captureStdOut(), "ghostty.sublime-syntax"); + _ = wf.addCopyFile(run.captureStdOut(.{}), "ghostty.sublime-syntax"); const install_step = b.addInstallDirectory(.{ .source_dir = wf.getDirectory(), @@ -358,10 +358,10 @@ fn addLinuxAppResources( // 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() }); + tail.setStdIn(.{ .lazy_path = tpl.getOutputFile() }); const copy = b.addInstallFile( - tail.captureStdOut(), + tail.captureStdOut(.{}), template[1], ); diff --git a/vendor/libghostty-vt/src/build/GhosttyWebdata.zig b/vendor/libghostty-vt/src/build/GhosttyWebdata.zig index e29b20c2..7057ad03 100644 --- a/vendor/libghostty-vt/src/build/GhosttyWebdata.zig +++ b/vendor/libghostty-vt/src/build/GhosttyWebdata.zig @@ -40,7 +40,7 @@ pub fn init( } const webgen_config_step = b.addRunArtifact(webgen_config); - const webgen_config_out = webgen_config_step.captureStdOut(); + const webgen_config_out = webgen_config_step.captureStdOut(.{}); try steps.append(b.allocator, &b.addInstallFile( webgen_config_out, @@ -71,7 +71,7 @@ pub fn init( } const webgen_actions_step = b.addRunArtifact(webgen_actions); - const webgen_actions_out = webgen_actions_step.captureStdOut(); + const webgen_actions_out = webgen_actions_step.captureStdOut(.{}); try steps.append(b.allocator, &b.addInstallFile( webgen_actions_out, @@ -102,7 +102,7 @@ pub fn init( } const webgen_commands_step = b.addRunArtifact(webgen_commands); - const webgen_commands_out = webgen_commands_step.captureStdOut(); + const webgen_commands_out = webgen_commands_step.captureStdOut(.{}); try steps.append(b.allocator, &b.addInstallFile( webgen_commands_out, diff --git a/vendor/libghostty-vt/src/build/GhosttyXCFramework.zig b/vendor/libghostty-vt/src/build/GhosttyXCFramework.zig index a19dd18a..3f3b97eb 100644 --- a/vendor/libghostty-vt/src/build/GhosttyXCFramework.zig +++ b/vendor/libghostty-vt/src/build/GhosttyXCFramework.zig @@ -24,35 +24,6 @@ pub fn init( Config.genericMacOSTarget(b, null), )); - // iOS - const ios = try GhosttyLib.initStatic(b, &try deps.retarget( - b, - b.resolveTargetQuery(.{ - .cpu_arch = .aarch64, - .os_tag = .ios, - .os_version_min = Config.osVersionMin(.ios), - .abi = null, - }), - )); - - // iOS Simulator - const ios_sim = try GhosttyLib.initStatic(b, &try deps.retarget( - b, - b.resolveTargetQuery(.{ - .cpu_arch = .aarch64, - .os_tag = .ios, - .os_version_min = Config.osVersionMin(.ios), - .abi = .simulator, - - // We force the Apple CPU model because the simulator - // doesn't support the generic CPU model as of Zig 0.14 due - // to missing "altnzcv" instructions, which is false. This - // surely can't be right but we can fix this if/when we get - // back to running simulator builds. - .cpu_model = .{ .explicit = &std.Target.aarch64.cpu.apple_a17 }, - }), - )); - // Generate a headers directory with only ghostty.h and the module // map. We can't use include/ directly because it also contains the // libghostty-vt headers under include/ghostty/, which would trigger @@ -75,16 +46,6 @@ pub fn init( .headers = headers, .dsym = macos_universal.dsym, }, - .{ - .library = ios.output, - .headers = headers, - .dsym = ios.dsym, - }, - .{ - .library = ios_sim.output, - .headers = headers, - .dsym = ios_sim.dsym, - }, }, .native => &.{.{ diff --git a/vendor/libghostty-vt/src/build/GhosttyXcodebuild.zig b/vendor/libghostty-vt/src/build/GhosttyXcodebuild.zig index 81af994c..6f7e18dc 100644 --- a/vendor/libghostty-vt/src/build/GhosttyXcodebuild.zig +++ b/vendor/libghostty-vt/src/build/GhosttyXcodebuild.zig @@ -48,21 +48,21 @@ pub fn init( }, }; - const env = try std.process.getEnvMap(b.allocator); + const env = b.graph.environ_map; const app_path = b.fmt("macos/build/{s}/Ghostty.app", .{xc_config}); // Our step to build the Ghostty macOS app. const build = build: { // External environment variables can mess up xcodebuild, so // we create a new empty environment. - const env_map = try b.allocator.create(std.process.EnvMap); + const env_map = try b.allocator.create(std.process.Environ.Map); env_map.* = .init(b.allocator); if (env.get("PATH")) |v| try env_map.put("PATH", v); const step = RunStep.create(b, "xcodebuild"); step.has_side_effects = true; step.cwd = b.path("macos"); - step.env_map = env_map; + step.environ_map = env_map; step.addArgs(&.{ "xcodebuild", "-target", @@ -91,14 +91,14 @@ pub fn init( }; const xctest = xctest: { - const env_map = try b.allocator.create(std.process.EnvMap); + const env_map = try b.allocator.create(std.process.Environ.Map); env_map.* = .init(b.allocator); if (env.get("PATH")) |v| try env_map.put("PATH", v); const step = RunStep.create(b, "xcodebuild test"); step.has_side_effects = true; step.cwd = b.path("macos"); - step.env_map = env_map; + step.environ_map = env_map; step.addArgs(&.{ "xcodebuild", "test", diff --git a/vendor/libghostty-vt/src/build/GhosttyZig.zig b/vendor/libghostty-vt/src/build/GhosttyZig.zig index 44c300e1..46ab05d5 100644 --- a/vendor/libghostty-vt/src/build/GhosttyZig.zig +++ b/vendor/libghostty-vt/src/build/GhosttyZig.zig @@ -64,7 +64,7 @@ fn initInner( vt_c_name: []const u8, ) !GhosttyZig { // Terminal module build options - var vt_options = cfg.terminalOptions(.lib); + var vt_options = cfg.terminalOptions(.lib, cfg.optimize); vt_options.artifact = .lib; // We presently don't allow Oniguruma in our Zig module at all. // We should expose this as a build option in the future so we can @@ -135,7 +135,21 @@ fn initVt( deps.unicode_tables.addModuleImport(vt); // We need uucode for grapheme break support - deps.addUucode(b, vt, cfg.target, cfg.optimize); + vt.addImport("uucode", deps.uucode_mod); + + // We need wuffs for Kitty graphics pixel operations (format + // conversion and alpha blending). Unlike pure Zig dependencies + // its C code is compiled whenever the module is in the build + // graph regardless of analysis, so only wire it in when Kitty + // graphics is actually enabled. + if (vt_options.kittyGraphics(cfg.target.result)) { + if (b.lazyDependency("wuffs", .{ + .target = cfg.target, + .optimize = cfg.optimize, + })) |dep| { + vt.addImport("wuffs", dep.module("wuffs")); + } + } // If SIMD is enabled, add all our SIMD dependencies. if (cfg.simd) { diff --git a/vendor/libghostty-vt/src/build/GitVersion.zig b/vendor/libghostty-vt/src/build/GitVersion.zig index 41cc7f84..2365a110 100644 --- a/vendor/libghostty-vt/src/build/GitVersion.zig +++ b/vendor/libghostty-vt/src/build/GitVersion.zig @@ -23,7 +23,7 @@ pub fn detect(b: *std.Build) !Version { const tmp: []u8 = b.runAllowFail( &[_][]const u8{ "git", "-C", b.build_root.path orelse ".", "rev-parse", "--abbrev-ref", "HEAD" }, &code, - .Ignore, + .ignore, ) catch |err| switch (err) { error.FileNotFound => return error.GitNotFound, error.ExitCodeFailure => return error.GitNotRepository, @@ -44,19 +44,19 @@ pub fn detect(b: *std.Build) !Version { const output = b.runAllowFail( &[_][]const u8{ "git", "-C", b.build_root.path orelse ".", "-c", "log.showSignature=false", "log", "--pretty=format:%h", "-n", "1" }, &code, - .Ignore, + .ignore, ) catch |err| switch (err) { error.FileNotFound => return error.GitNotFound, else => return err, }; - break :short_hash std.mem.trimRight(u8, output, "\r\n "); + break :short_hash std.mem.trimEnd(u8, output, "\r\n "); }; const tag = b.runAllowFail( &[_][]const u8{ "git", "-C", b.build_root.path orelse ".", "describe", "--exact-match", "--tags" }, &code, - .Ignore, + .ignore, ) catch |err| switch (err) { error.FileNotFound => return error.GitNotFound, error.ExitCodeFailure => "", // expected @@ -70,7 +70,7 @@ pub fn detect(b: *std.Build) !Version { "diff", "--quiet", "--exit-code", - }, &code, .Ignore) catch |err| switch (err) { + }, &code, .ignore) catch |err| switch (err) { error.FileNotFound => return error.GitNotFound, error.ExitCodeFailure => {}, // expected else => return err, @@ -80,7 +80,7 @@ pub fn detect(b: *std.Build) !Version { return .{ .short_hash = short_hash, .changes = changes, - .tag = if (tag.len > 0) std.mem.trimRight(u8, tag, "\r\n ") else null, - .branch = std.mem.trimRight(u8, branch, "\r\n "), + .tag = if (tag.len > 0) std.mem.trimEnd(u8, tag, "\r\n ") else null, + .branch = std.mem.trimEnd(u8, branch, "\r\n "), }; } diff --git a/vendor/libghostty-vt/src/build/HelpStrings.zig b/vendor/libghostty-vt/src/build/HelpStrings.zig index 96505cab..24ae82e2 100644 --- a/vendor/libghostty-vt/src/build/HelpStrings.zig +++ b/vendor/libghostty-vt/src/build/HelpStrings.zig @@ -34,7 +34,7 @@ pub fn init(b: *std.Build, cfg: *const Config) !HelpStrings { // Generated Zig files have to end with .zig const wf = b.addWriteFiles(); - const output = wf.addCopyFile(help_run.captureStdOut(), "helpgen.zig"); + const output = wf.addCopyFile(help_run.captureStdOut(.{}), "helpgen.zig"); return .{ .exe = exe, diff --git a/vendor/libghostty-vt/src/build/LibsystemOverrideStep.zig b/vendor/libghostty-vt/src/build/LibsystemOverrideStep.zig new file mode 100644 index 00000000..3e1cafa5 --- /dev/null +++ b/vendor/libghostty-vt/src/build/LibsystemOverrideStep.zig @@ -0,0 +1,51 @@ +//! Wraps a Darwin static archive with the libsystem_override.sh +//! post-processing step so that consumers linking the archive bind +//! well-known libc/libm symbols (memcpy, memmove, memset, cos, sin, +//! ...) to Apple's libSystem instead of the bundled Zig compiler-rt. +//! See src/build/libsystem_override.sh for the full rationale. +//! +//! This applies to every static archive we produce, public +//! (e.g. libghostty-vt) and app-internal alike: the compiler-rt +//! bundle must stay because the few symbols libSystem does not +//! provide (f128 conversions, *q math, sincos*, ___zig_probe_stack) +//! have live references, e.g. from the bundled ubsan runtime and +//! x86_64 stack probing in Debug builds. +//! +//! Note shared libraries can't use either approach: Zig 0.16 links +//! compiler-rt as an eagerly-included object, so its definitions are +//! already bound into the dylib at link time and cannot be repaired +//! post-hoc. That needs an upstream Zig fix. +const std = @import("std"); +const builtin = @import("builtin"); + +pub const Result = struct { + /// The step performing the override, or null if the override + /// doesn't apply to this target/host (in which case `output` is + /// the unmodified input). + step: ?*std.Build.Step, + output: std.Build.LazyPath, +}; + +/// Post-process the given static archive on Darwin. Requires a Darwin +/// host for the Apple toolchain (nmedit); in all other configurations +/// this is a no-op and the input is returned unmodified, which is +/// functional (the bundled compiler-rt symbols are used), just slower. +pub fn create( + b: *std.Build, + target: std.Build.ResolvedTarget, + input: std.Build.LazyPath, + out_name: []const u8, +) Result { + if (!target.result.os.tag.isDarwin() or + comptime !builtin.os.tag.isDarwin()) + { + return .{ .step = null, .output = input }; + } + + const run = b.addSystemCommand(&.{"/bin/sh"}); + run.setName("libsystem override"); + run.addFileArg(b.path("src/build/libsystem_override.sh")); + run.addFileArg(input); + const output = run.addOutputFileArg(out_name); + return .{ .step = &run.step, .output = output }; +} diff --git a/vendor/libghostty-vt/src/build/MetallibStep.zig b/vendor/libghostty-vt/src/build/MetallibStep.zig index fcf3055f..81c7a4a9 100644 --- a/vendor/libghostty-vt/src/build/MetallibStep.zig +++ b/vendor/libghostty-vt/src/build/MetallibStep.zig @@ -24,20 +24,10 @@ output: LazyPath, pub fn create(b: *std.Build, opts: Options) ?*MetallibStep { const sdk = switch (opts.target.result.os.tag) { .macos => "macosx", - .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, }; @@ -47,7 +37,6 @@ pub fn create(b: *std.Build, opts: Options) ?*MetallibStep { b.fmt("{f}", .{v.semver}) else switch (opts.target.result.os.tag) { .macos => "10.14", - .ios => "11.0", else => unreachable, }; diff --git a/vendor/libghostty-vt/src/build/SharedDeps.zig b/vendor/libghostty-vt/src/build/SharedDeps.zig index b68be92d..72abfb9a 100644 --- a/vendor/libghostty-vt/src/build/SharedDeps.zig +++ b/vendor/libghostty-vt/src/build/SharedDeps.zig @@ -9,6 +9,7 @@ const MetallibStep = @import("MetallibStep.zig"); const UnicodeTables = @import("UnicodeTables.zig"); const GhosttyFrameData = @import("GhosttyFrameData.zig"); const DistResource = @import("GhosttyDist.zig").Resource; +const gtk_helpers = @import("gtk.zig"); config: *const Config, @@ -19,6 +20,43 @@ unicode_tables: UnicodeTables, framedata: GhosttyFrameData, uucode_tables: std.Build.LazyPath, +/// Singleton uucode module, instantiated once in `init` and reused +/// everywhere so that ghostty and vaxis share the same compiled tables in +/// each final binary instead of each linking its own copy. +/// +/// Sharing one instance is also a hard requirement (not just an +/// optimization) for Zig 0.16's strict module model. `SharedDeps.add` runs +/// many times across different (target, optimize) tuples (macos-aarch64, +/// macos-x86_64, ios-aarch64, Debug + ReleaseFast, etc.), and on each +/// call we have to wire uucode into both the step's root module and into +/// vaxis_mod (because vaxis's `Parser.zig` does `@import("uucode")` and +/// we pass `external_uucode = true` to vaxis's build.zig so vaxis doesn't +/// instantiate its own uucode dep). If those two import bindings ever +/// resolve to *different* `*Module` pointers within a single Compile +/// step's analysis, Zig fails with: +/// +/// vaxis/src/Parser.zig: file exists in modules 'uucode' and 'uucode0' +/// +/// because all those uucode module instances share the same physical +/// `uucode/src/root.zig` file on disk, and Zig requires every file to belong +/// to exactly one module within a Compile graph. +/// +/// The natural way to keep them the same would be to call +/// `b.lazyDependency("uucode", .{ .tables_path, .build_config_path })` +/// from each call site and let Zig's dependency cache deduplicate +/// identical args. That fails because of a bug in Zig's +/// `userLazyPathsAreTheSame` (Build.zig) where the `.src_path` and +/// `.generated` equality checks are inverted: `if (std.mem.eql(...)) +/// return false` instead of `if (!std.mem.eql(...)) return false`. The +/// dep cache key therefore always misses whenever any arg is a +/// `b.path(...)` LazyPath, so each call returns a fresh `*Dependency` +/// with a fresh `*Module`. Hoisting the dep into one eager +/// `b.dependency` call here sidesteps the cache entirely. +/// +/// This conflict is independent of whether vaxis itself is acquired as a +/// singleton or per-target dep. +uucode_mod: *std.Build.Module, + /// Used to keep track of a list of file sources. pub const LazyPathList = std.ArrayList(std.Build.LazyPath); @@ -31,12 +69,26 @@ pub fn init(b: *std.Build, cfg: *const Config) !SharedDeps { break :blk uucode.namedLazyPath("tables.zig"); }; + // Instantiate the singleton uucode module that both ghostty and vaxis + // import. See the doc comment on `uucode_mod`. + const uucode_mod = b.dependency("uucode", .{ + .tables_path = uucode_tables, + .build_config_path = b.path("src/build/uucode_config.zig"), + }).module("uucode"); + + // Re-export the uucode module so that Zig programs that embed libgtostty-vt + // can use it. This is necessary to use libraries like libvaxis in + // the embedding program that need uucode as well (libvaxis provides + // -Dexternal_uucode for this). + try b.modules.put(b.allocator, b.dupe("uucode"), uucode_mod); + var result: SharedDeps = .{ .config = cfg, .help_strings = try .init(b, cfg), .unicode_tables = try .init(b, uucode_tables), .framedata = try .init(b), .uucode_tables = uucode_tables, + .uucode_mod = uucode_mod, // Setup by retarget .options = undefined, @@ -133,7 +185,10 @@ pub fn add( step.root_module.addOptions("build_options", self.options); // Every exe needs the terminal options - self.config.terminalOptions(.ghostty).add(b, step.root_module); + self.config.terminalOptions(.ghostty, optimize).add(b, step.root_module); + + // Every exe needs the uucode module + step.root_module.addImport("uucode", self.uucode_mod); // C imports for locale constants and functions { @@ -143,11 +198,15 @@ pub fn add( .optimize = optimize, }); if (target.result.os.tag.isDarwin()) { - const libc = try std.zig.LibCInstallation.findNative(.{ - .allocator = b.allocator, - .target = &target.result, - .verbose = false, - }); + const libc = try std.zig.LibCInstallation.findNative( + b.allocator, + b.graph.io, + .{ + .environ_map = &b.graph.environ_map, + .target = &target.result, + .verbose = false, + }, + ); c.addSystemIncludePath(.{ .cwd_relative = libc.sys_include_dir.? }); } step.root_module.addImport("locale-c", c.createModule()); @@ -166,11 +225,15 @@ pub fn add( }); switch (target.result.os.tag) { .macos => { - const libc = try std.zig.LibCInstallation.findNative(.{ - .allocator = b.allocator, - .target = &target.result, - .verbose = false, - }); + const libc = try std.zig.LibCInstallation.findNative( + b.allocator, + b.graph.io, + .{ + .environ_map = &b.graph.environ_map, + .target = &target.result, + .verbose = false, + }, + ); c.addSystemIncludePath(.{ .cwd_relative = libc.sys_include_dir.? }); }, else => {}, @@ -194,10 +257,10 @@ pub fn add( ); if (b.systemIntegrationOption("freetype", .{})) { - step.linkSystemLibrary2("bzip2", dynamic_link_opts); - step.linkSystemLibrary2("freetype2", dynamic_link_opts); + step.root_module.linkSystemLibrary("bzip2", dynamic_link_opts); + step.root_module.linkSystemLibrary("freetype2", dynamic_link_opts); } else { - step.linkLibrary(freetype_dep.artifact("freetype")); + step.root_module.linkLibrary(freetype_dep.artifact("freetype")); try static_libs.append( b.allocator, freetype_dep.artifact("freetype").getEmittedBin(), @@ -219,9 +282,9 @@ pub fn add( harfbuzz_dep.module("harfbuzz"), ); if (b.systemIntegrationOption("harfbuzz", .{})) { - step.linkSystemLibrary2("harfbuzz", dynamic_link_opts); + step.root_module.linkSystemLibrary("harfbuzz", dynamic_link_opts); } else { - step.linkLibrary(harfbuzz_dep.artifact("harfbuzz")); + step.root_module.linkLibrary(harfbuzz_dep.artifact("harfbuzz")); try static_libs.append( b.allocator, harfbuzz_dep.artifact("harfbuzz").getEmittedBin(), @@ -243,9 +306,9 @@ pub fn add( ); if (b.systemIntegrationOption("fontconfig", .{})) { - step.linkSystemLibrary2("fontconfig", dynamic_link_opts); + step.root_module.linkSystemLibrary("fontconfig", dynamic_link_opts); } else { - step.linkLibrary(fontconfig_dep.artifact("fontconfig")); + step.root_module.linkLibrary(fontconfig_dep.artifact("fontconfig")); try static_libs.append( b.allocator, fontconfig_dep.artifact("fontconfig").getEmittedBin(), @@ -263,7 +326,7 @@ pub fn add( .target = target, .optimize = optimize, })) |libpng_dep| { - step.linkLibrary(libpng_dep.artifact("png")); + step.root_module.linkLibrary(libpng_dep.artifact("png")); try static_libs.append( b.allocator, libpng_dep.artifact("png").getEmittedBin(), @@ -277,7 +340,7 @@ pub fn add( .target = target, .optimize = optimize, })) |zlib_dep| { - step.linkLibrary(zlib_dep.artifact("z")); + step.root_module.linkLibrary(zlib_dep.artifact("z")); try static_libs.append( b.allocator, zlib_dep.artifact("z").getEmittedBin(), @@ -295,9 +358,9 @@ pub fn add( oniguruma_dep.module("oniguruma"), ); if (b.systemIntegrationOption("oniguruma", .{})) { - step.linkSystemLibrary2("oniguruma", dynamic_link_opts); + step.root_module.linkSystemLibrary("oniguruma", dynamic_link_opts); } else { - step.linkLibrary(oniguruma_dep.artifact("oniguruma")); + step.root_module.linkLibrary(oniguruma_dep.artifact("oniguruma")); try static_libs.append( b.allocator, oniguruma_dep.artifact("oniguruma").getEmittedBin(), @@ -312,13 +375,13 @@ pub fn add( })) |glslang_dep| { step.root_module.addImport("glslang", glslang_dep.module("glslang")); if (b.systemIntegrationOption("glslang", .{})) { - step.linkSystemLibrary2("glslang", dynamic_link_opts); - step.linkSystemLibrary2( + step.root_module.linkSystemLibrary("glslang", dynamic_link_opts); + step.root_module.linkSystemLibrary( "glslang-default-resource-limits", dynamic_link_opts, ); } else { - step.linkLibrary(glslang_dep.artifact("glslang")); + step.root_module.linkLibrary(glslang_dep.artifact("glslang")); try static_libs.append( b.allocator, glslang_dep.artifact("glslang").getEmittedBin(), @@ -336,9 +399,9 @@ pub fn add( spirv_cross_dep.module("spirv_cross"), ); if (b.systemIntegrationOption("spirv-cross", .{})) { - step.linkSystemLibrary2("spirv-cross-c-shared", dynamic_link_opts); + step.root_module.linkSystemLibrary("spirv-cross-c-shared", dynamic_link_opts); } else { - step.linkLibrary(spirv_cross_dep.artifact("spirv_cross")); + step.root_module.linkLibrary(spirv_cross_dep.artifact("spirv_cross")); try static_libs.append( b.allocator, spirv_cross_dep.artifact("spirv_cross").getEmittedBin(), @@ -357,7 +420,7 @@ pub fn add( "sentry", sentry_dep.module("sentry"), ); - step.linkLibrary(sentry_dep.artifact("sentry")); + step.root_module.linkLibrary(sentry_dep.artifact("sentry")); try static_libs.append( b.allocator, sentry_dep.artifact("sentry").getEmittedBin(), @@ -404,18 +467,18 @@ pub fn add( if (step.rootModuleTarget().os.tag == .linux) { const triple = try step.rootModuleTarget().linuxTriple(b.allocator); const path = b.fmt("/usr/lib/{s}", .{triple}); - if (std.fs.accessAbsolute(path, .{})) { - step.addLibraryPath(.{ .cwd_relative = path }); + if (std.Io.Dir.accessAbsolute(b.graph.io, path, .{})) { + step.root_module.addLibraryPath(.{ .cwd_relative = path }); } else |_| {} } // C files - step.linkLibC(); - step.addIncludePath(b.path("src/stb")); + step.root_module.link_libc = true; + step.root_module.addIncludePath(b.path("src/stb")); // Disable ubsan for MSVC: Zig's ubsan runtime cannot be bundled // on Windows (LNK4229), leaving __ubsan_handle_* unresolved when // the static archive is consumed by an external linker. - step.addCSourceFiles(.{ + step.root_module.addCSourceFiles(.{ .files = &.{"src/stb/stb.c"}, .flags = if (step.rootModuleTarget().abi == .msvc) &.{ "-fno-sanitize=undefined", "-fno-sanitize-trap=undefined" } @@ -423,7 +486,7 @@ pub fn add( &.{}, }); if (step.rootModuleTarget().os.tag == .linux) { - step.addIncludePath(b.path("src/apprt/gtk")); + step.root_module.addIncludePath(b.path("src/apprt/gtk")); } // libcpp is required for various dependencies. On MSVC, we must @@ -433,7 +496,7 @@ pub fn add( // include directories (already added via linkLibC above) contain // both C and C++ headers, so linkLibCpp is not needed. if (step.rootModuleTarget().abi != .msvc) { - step.linkLibCpp(); + step.root_module.link_libcpp = true; } // We always require the system SDK so that our system headers are available. @@ -452,8 +515,14 @@ pub fn add( if (b.lazyDependency("opengl", .{})) |dep| { step.root_module.addImport("opengl", dep.module("opengl")); } - if (b.lazyDependency("vaxis", .{})) |dep| { - step.root_module.addImport("vaxis", dep.module("vaxis")); + if (b.lazyDependency("vaxis", .{ + .target = target, + .optimize = optimize, + .external_uucode = true, + })) |dep| { + const vaxis = dep.module("vaxis"); + step.root_module.addImport("vaxis", vaxis); + vaxis.addImport("uucode", self.uucode_mod); } if (b.lazyDependency("wuffs", .{ .target = target, @@ -473,7 +542,6 @@ pub fn add( })) |dep| { step.root_module.addImport("z2d", dep.module("z2d")); } - self.addUucode(b, step.root_module, target, optimize); if (b.lazyDependency("zf", .{ .target = target, .optimize = optimize, @@ -502,7 +570,7 @@ pub fn add( "macos", macos_dep.module("macos"), ); - step.linkLibrary( + step.root_module.linkLibrary( macos_dep.artifact("macos"), ); try static_libs.append( @@ -512,7 +580,7 @@ pub fn add( } if (self.config.renderer == .opengl) { - step.linkFramework("OpenGL"); + step.root_module.linkFramework("OpenGL", .{}); } // Apple platforms do not include libc libintl so we bundle it. @@ -523,7 +591,7 @@ pub fn add( .target = target, .optimize = optimize, })) |libintl_dep| { - step.linkLibrary(libintl_dep.artifact("intl")); + step.root_module.linkLibrary(libintl_dep.artifact("intl")); try static_libs.append( b.allocator, libintl_dep.artifact("intl").getEmittedBin(), @@ -543,7 +611,7 @@ pub fn add( .@"backend-opengl3" = !target.result.os.tag.isDarwin(), })) |dep| { step.root_module.addImport("dcimgui", dep.module("dcimgui")); - step.linkLibrary(dep.artifact("dcimgui")); + step.root_module.linkLibrary(dep.artifact("dcimgui")); try static_libs.append( b.allocator, dep.artifact("dcimgui").getEmittedBin(), @@ -592,15 +660,38 @@ pub fn add( // If we're building an exe then we have additional dependencies. if (step.kind != .lib) { // We always statically compile glad - step.addIncludePath(b.path("vendor/glad/include/")); - step.addCSourceFile(.{ + step.root_module.addIncludePath(b.path("vendor/glad/include/")); + step.root_module.addCSourceFile(.{ .file = b.path("vendor/glad/src/gl.c"), .flags = &.{}, }); // When we're targeting flatpak we ALWAYS link GTK so we // get access to glib for dbus. - if (self.config.flatpak) step.linkSystemLibrary2("gtk4", dynamic_link_opts); + if (self.config.flatpak) { + step.root_module.linkSystemLibrary("gtk4", dynamic_link_opts); + + // We need to translate gio headers too + gio_translate: { + // translate-c stuff + const translate_c = b.lazyImport(@import("../../build.zig"), "translate_c") orelse + break :gio_translate; + const translate_c_dep = b.lazyDependency("translate_c", .{}) orelse + break :gio_translate; + const translated: translate_c.Translator = .init(translate_c_dep, .{ + .c_source_file = b.addWriteFiles().add("gio_c.h", + \\#include + \\#include + ), + .target = target, + .optimize = optimize, + .link_system_libs = &.{ + .{ .name = "gio-2.0", .options = dynamic_link_opts }, + }, + }); + step.root_module.addImport("gio_c", translated.mod); + } + } switch (self.config.app_runtime) { .none => {}, @@ -634,6 +725,7 @@ fn addGtkNg( .{ "gdk", "gdk4" }, .{ "gio", "gio2" }, .{ "glib", "glib2" }, + .{ "glibunix", "glibunix2" }, .{ "gobject", "gobject2" }, .{ "gtk", "gtk4" }, .{ "xlib", "xlib2" }, @@ -643,12 +735,44 @@ fn addGtkNg( step.root_module.addImport(name, gobject.module(module)); } } - - step.linkSystemLibrary2("gtk4", dynamic_link_opts); - step.linkSystemLibrary2("libadwaita-1", dynamic_link_opts); + gtk_adw_translate: { + // translate-c stuff + const translate_c = b.lazyImport(@import("../../build.zig"), "translate_c") orelse break :gtk_adw_translate; + const translate_c_dep = b.lazyDependency("translate_c", .{}) orelse break :gtk_adw_translate; + const Translator = translate_c.Translator; + + { + // GTK headers + const translated: Translator = .init(translate_c_dep, .{ + .c_source_file = b.addWriteFiles().add("gtk_c.h", + \\#include + ), + .target = target, + .optimize = optimize, + .link_system_libs = &.{ + .{ .name = "gtk4", .options = dynamic_link_opts }, + }, + }); + step.root_module.addImport("gtk_c", translated.mod); + } + { + // Adwaita headers + const translated: Translator = .init(translate_c_dep, .{ + .c_source_file = b.addWriteFiles().add("adw_c.h", + \\#include + ), + .target = target, + .optimize = optimize, + .link_system_libs = &.{ + .{ .name = "libadwaita-1", .options = dynamic_link_opts }, + }, + }); + step.root_module.addImport("adw_c", translated.mod); + } + } if (self.config.x11) { - step.linkSystemLibrary2("X11", dynamic_link_opts); + step.root_module.linkSystemLibrary("X11", dynamic_link_opts); if (gobject_) |gobject| { step.root_module.addImport( "gdk_x11", @@ -699,13 +823,19 @@ fn addGtkNg( ); scanner.addSystemProtocol("staging/xdg-activation/xdg-activation-v1.xml"); scanner.addSystemProtocol("staging/ext-background-effect/ext-background-effect-v1.xml"); + scanner.addCustomProtocol( + b.path("src/apprt/gtk/winproto/wayland/protocols/vicinae-hotkey-v1.xml"), + ); scanner.generate("wl_compositor", 1); + // Only referenced by vicinae_hotkey_manager_v1.bind (nullable arg). + scanner.generate("wl_seat", 1); scanner.generate("org_kde_kwin_server_decoration_manager", 1); scanner.generate("org_kde_kwin_slide_manager", 1); scanner.generate("kde_output_order_v1", 1); scanner.generate("xdg_activation_v1", 1); scanner.generate("ext_background_effect_manager_v1", 1); + scanner.generate("vicinae_hotkey_manager_v1", 1); step.root_module.addImport("wayland", b.createModule(.{ .root_source_file = scanner.result, @@ -732,24 +862,39 @@ fn addGtkNg( // 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.root_module.linkSystemLibrary("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 const shared_lib = gtk4_layer_shell.artifact("gtk4-layer-shell"); b.installArtifact(shared_lib); - step.linkLibrary(shared_lib); + step.root_module.linkLibrary(shared_lib); } } - step.linkSystemLibrary2("wayland-client", dynamic_link_opts); + step.root_module.linkSystemLibrary("wayland-client", dynamic_link_opts); } - { + ghostty_resources_translate: { // Get our gresource c/h files and add them to our build. const dist = gtkNgDistResources(b); - step.addCSourceFile(.{ .file = dist.resources_c.path(b), .flags = &.{} }); - step.addIncludePath(dist.resources_h.path(b).dirname()); + const translate_c = b.lazyImport(@import("../../build.zig"), "translate_c") orelse + break :ghostty_resources_translate; + const translate_c_dep = b.lazyDependency("translate_c", .{}) orelse + break :ghostty_resources_translate; + const translated: translate_c.Translator = .init(translate_c_dep, .{ + .c_source_file = b.addWriteFiles().add("c.h", + \\#include + ), + .target = target, + .optimize = optimize, + .link_system_libs = &.{ + .{ .name = "glib-2.0", .options = dynamic_link_opts }, + }, + }); + translated.addIncludePath(dist.resources_h.path(b).dirname()); + translated.mod.addCSourceFile(.{ .file = dist.resources_c.path(b), .flags = &.{} }); + step.root_module.addImport("ghostty_gtk_resources", translated.mod); } } @@ -853,6 +998,11 @@ pub fn addSimd( "-fno-sanitize=undefined", "-fno-sanitize-trap=undefined", }); + if (target.result.abi == .msvc) try flags.appendSlice(b.allocator, &.{ + // -fno-autolink also drops UCRT's /alternatename fallback. + "-D_Avx2WmemEnabledWeakValue=_Avx2WmemEnabled", + "-fno-autolink", + }); m.addCSourceFiles(.{ .files = &.{ @@ -890,11 +1040,25 @@ pub fn gtkNgDistResources( .root_module = b.createModule(.{ .root_source_file = b.path("src/apprt/gtk/build/blueprint.zig"), .target = b.graph.host, + .link_libc = true, }), }); - blueprint_exe.linkLibC(); - blueprint_exe.linkSystemLibrary2("gtk4", dynamic_link_opts); - blueprint_exe.linkSystemLibrary2("libadwaita-1", dynamic_link_opts); + adw_translate: { + // Adwaita headers + const translate_c = b.lazyImport(@import("../../build.zig"), "translate_c") orelse break :adw_translate; + const translate_c_dep = b.lazyDependency("translate_c", .{}) orelse break :adw_translate; + const translated: translate_c.Translator = .init(translate_c_dep, .{ + .c_source_file = b.addWriteFiles().add("adw_c.h", + \\#include + ), + .target = b.graph.host, + .optimize = .Debug, + .link_system_libs = &.{ + .{ .name = "libadwaita-1", .options = dynamic_link_opts }, + }, + }); + blueprint_exe.root_module.addImport("adw_c", translated.mod); + } for (gresource.blueprints) |bp| { const blueprint_run = b.addRunArtifact(blueprint_exe); @@ -923,7 +1087,7 @@ pub fn gtkNgDistResources( xml_run.addFileArg(ui_file); } - break :gresource_xml xml_run.captureStdOut(); + break :gresource_xml xml_run.captureStdOut(.{}); }; const generate_c = b.addSystemCommand(&.{ @@ -964,23 +1128,6 @@ pub fn gtkNgDistResources( }; } -pub fn addUucode( - self: *const SharedDeps, - b: *std.Build, - module: *std.Build.Module, - target: std.Build.ResolvedTarget, - optimize: std.builtin.OptimizeMode, -) void { - if (b.lazyDependency("uucode", .{ - .target = target, - .optimize = optimize, - .tables_path = self.uucode_tables, - .build_config_path = b.path("src/build/uucode_config.zig"), - })) |dep| { - module.addImport("uucode", dep.module("uucode")); - } -} - // For dynamic linking, we prefer dynamic linking and to search by // mode first. Mode first will search all paths for a dynamic library // before falling back to static. diff --git a/vendor/libghostty-vt/src/build/UnicodeTables.zig b/vendor/libghostty-vt/src/build/UnicodeTables.zig index 17a839ea..843ada51 100644 --- a/vendor/libghostty-vt/src/build/UnicodeTables.zig +++ b/vendor/libghostty-vt/src/build/UnicodeTables.zig @@ -54,8 +54,8 @@ pub fn init(b: *std.Build, uucode_tables: std.Build.LazyPath) !UnicodeTables { // Generated Zig files have to end with .zig const wf = b.addWriteFiles(); - const props_output = wf.addCopyFile(props_run.captureStdOut(), "props.zig"); - const symbols_output = wf.addCopyFile(symbols_run.captureStdOut(), "symbols.zig"); + const props_output = wf.addCopyFile(props_run.captureStdOut(.{}), "props.zig"); + const symbols_output = wf.addCopyFile(symbols_run.captureStdOut(.{}), "symbols.zig"); return .{ .props_exe = props_exe, diff --git a/vendor/libghostty-vt/src/build/XCFrameworkStep.zig b/vendor/libghostty-vt/src/build/XCFrameworkStep.zig index 39f0f9ba..5f1b676f 100644 --- a/vendor/libghostty-vt/src/build/XCFrameworkStep.zig +++ b/vendor/libghostty-vt/src/build/XCFrameworkStep.zig @@ -63,8 +63,8 @@ pub fn create(b: *std.Build, opts: Options) *XCFrameworkStep { run.addArg("-output"); run.addArg(opts.out_path); run.expectExitCode(0); - _ = run.captureStdOut(); - _ = run.captureStdErr(); + _ = run.captureStdOut(.{}); + _ = run.captureStdErr(.{}); break :run run; }; run_create.step.dependOn(&run_delete.step); diff --git a/vendor/libghostty-vt/src/build/combine_archives.zig b/vendor/libghostty-vt/src/build/combine_archives.zig index 04ec8e97..3df5ed0e 100644 --- a/vendor/libghostty-vt/src/build/combine_archives.zig +++ b/vendor/libghostty-vt/src/build/combine_archives.zig @@ -11,11 +11,10 @@ const std = @import("std"); -pub fn main() !void { - var gpa: std.heap.GeneralPurposeAllocator(.{}) = .init; - const alloc = gpa.allocator(); +pub fn main(init: std.process.Init) !void { + const alloc = init.arena.allocator(); - const args = try std.process.argsAlloc(alloc); + const args = try init.minimal.args.toSlice(alloc); if (args.len < 4) { std.log.err("usage: combine_archives ", .{}); std.process.exit(1); @@ -37,19 +36,20 @@ pub fn main() !void { } try script.appendSlice(alloc, "SAVE\nEND\n"); - var child: std.process.Child = .init(&.{ zig_exe, "ar", "-M" }, alloc); - child.stdin_behavior = .Pipe; - child.stdout_behavior = .Inherit; - child.stderr_behavior = .Inherit; + var child = try std.process.spawn(init.io, .{ + .argv = &.{ zig_exe, "ar", "-M" }, + .stdin = .pipe, + .stdout = .inherit, + .stderr = .inherit, + }); - try child.spawn(); - try child.stdin.?.writeAll(script.items); - child.stdin.?.close(); + try child.stdin.?.writeStreamingAll(init.io, script.items); + child.stdin.?.close(init.io); child.stdin = null; - const term = try child.wait(); - if (term.Exited != 0) { - std.log.err("zig ar -M exited with code {d}", .{term.Exited}); + const term = try child.wait(init.io); + if (term.exited != 0) { + std.log.err("zig ar -M exited with code {d}", .{term.exited}); std.process.exit(1); } } diff --git a/vendor/libghostty-vt/src/build/docker/lib-c-docs/Dockerfile b/vendor/libghostty-vt/src/build/docker/lib-c-docs/Dockerfile index a3cfdcc9..b5d17616 100644 --- a/vendor/libghostty-vt/src/build/docker/lib-c-docs/Dockerfile +++ b/vendor/libghostty-vt/src/build/docker/lib-c-docs/Dockerfile @@ -14,6 +14,7 @@ WORKDIR /ghostty COPY include/ ./include/ COPY images/ ./images/ COPY dist/doxygen/ ./dist/doxygen/ +COPY src/terminal/c/types.schema.json ./src/terminal/c/types.schema.json COPY example/ ./example/ COPY Doxyfile ./ COPY DoxygenLayout.xml ./ diff --git a/vendor/libghostty-vt/src/build/gtk.zig b/vendor/libghostty-vt/src/build/gtk.zig index 7adb3cdb..1035367e 100644 --- a/vendor/libghostty-vt/src/build/gtk.zig +++ b/vendor/libghostty-vt/src/build/gtk.zig @@ -14,7 +14,7 @@ pub fn targets(b: *std.Build) Targets { const output = b.runAllowFail( &.{ "pkg-config", "--variable=targets", "gtk4" }, &code, - .Ignore, + .ignore, ) catch return .{}; const x11 = std.mem.indexOf(u8, output, "x11") != null; diff --git a/vendor/libghostty-vt/src/build/libsystem_override.sh b/vendor/libghostty-vt/src/build/libsystem_override.sh new file mode 100755 index 00000000..14ed191b --- /dev/null +++ b/vendor/libghostty-vt/src/build/libsystem_override.sh @@ -0,0 +1,123 @@ +#!/bin/sh +# +# Darwin-only: rewrite a static archive so that consumers linking it bind +# libc/libm symbols to Apple's libSystem instead of the bundled Zig +# compiler-rt. This uses the Apple toolchain (xcrun, nmedit) and libSystem +# semantics, so it must run on a Darwin host for a Darwin target. +# +# Background: our static library bundles Zig's compiler-rt, which defines +# strong global implementations of libc/libm functions (memcpy, memmove, +# memset, cos, sin, ...). When a consumer (e.g. the macOS app via the +# XCFramework) links this archive, ld64 resolves those symbols from the +# archive instead of libSystem, silently replacing Apple's highly +# optimized implementations (_platform_memmove and friends, vectorized +# libm) with compiler-rt's generic ones. We measured Zig 0.16 (LLVM 21) +# compiler-rt memmove costing several percent of PTY throughput on +# scroll-region workloads versus libSystem's. +# +# The mechanism is to localize (make non-external) the libSystem-provided +# symbols defined by the compiler_rt.o archive member. This keeps +# compiler_rt.o functional for the intrinsics that libSystem does NOT +# provide (e.g. the f128 conversions ___extenddftf2/___extendxftf2, *q +# math, sincos*) while letting every other archive member bind the +# well-known libc/libm symbols to libSystem at final link. +# +# usage: libsystem_override.sh +set -eu + +in="$1" +out="$2" + +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT + +cp -f "$in" "$out" +chmod u+w "$out" + +# The symbols to prefer from libSystem. Everything listed here is a +# stable macOS export (verified against libSystem's link surface). +# Notably NOT listed (not exported by libSystem): the *q (f128) +# variants, sincos/sincosf/sincosl, and all ___-prefixed compiler +# intrinsics except the fortify _chk wrappers below. +cat >"$tmp/localize.txt" <<'EOF' +_bcmp +_memcmp +_memcpy +_memmove +_memset +_strlen +___memcpy_chk +___memmove_chk +___memset_chk +___strcat_chk +___strcpy_chk +_ceil +_ceilf +_ceill +_cos +_cosf +_cosl +_exp +_exp2 +_exp2f +_exp2l +_expf +_expl +_fabs +_fabsf +_fabsl +_floor +_floorf +_floorl +_fma +_fmaf +_fmal +_fmax +_fmaxf +_fmaxl +_fmin +_fminf +_fminl +_fmod +_fmodf +_fmodl +_log +_log10 +_log10f +_log10l +_log2 +_log2f +_log2l +_logf +_logl +_round +_roundf +_roundl +_sin +_sinf +_sinl +_sqrt +_sqrtf +_sqrtl +_tan +_tanf +_tanl +_trunc +_truncf +_truncl +EOF + +cd "$tmp" + +xcrun ar x "$out" compiler_rt.o +chmod 644 compiler_rt.o + +# nmedit takes a keep-list (-s): all current global definitions except +# the ones we want to localize. +xcrun nm -g compiler_rt.o | awk '$2 ~ /^[A-TV-Z]$/ {print $3}' | sort -u >all.txt +sort -u localize.txt >loc.txt +comm -23 all.txt loc.txt >keep.txt +xcrun nmedit -s keep.txt compiler_rt.o + +xcrun ar r "$out" compiler_rt.o +xcrun ranlib "$out" 2>/dev/null || true diff --git a/vendor/libghostty-vt/src/build/mdgen/main_ghostty_1.zig b/vendor/libghostty-vt/src/build/mdgen/main_ghostty_1.zig index 2bb413d9..e511768c 100644 --- a/vendor/libghostty-vt/src/build/mdgen/main_ghostty_1.zig +++ b/vendor/libghostty-vt/src/build/mdgen/main_ghostty_1.zig @@ -1,12 +1,11 @@ const std = @import("std"); const gen = @import("mdgen.zig"); -pub fn main() !void { - var gpa: std.heap.GeneralPurposeAllocator(.{}) = .init; - const alloc = gpa.allocator(); +pub fn main(init: std.process.Init) !void { + const alloc = init.arena.allocator(); var buffer: [1024]u8 = undefined; - var stdout_writer = std.fs.File.stdout().writer(&buffer); + var stdout_writer = std.Io.File.stdout().writer(init.io, &buffer); const writer = &stdout_writer.interface; try gen.substitute(alloc, @embedFile("ghostty_1_header.md"), writer); try gen.genActions(writer); diff --git a/vendor/libghostty-vt/src/build/mdgen/main_ghostty_5.zig b/vendor/libghostty-vt/src/build/mdgen/main_ghostty_5.zig index 2123b0bc..4f7c4882 100644 --- a/vendor/libghostty-vt/src/build/mdgen/main_ghostty_5.zig +++ b/vendor/libghostty-vt/src/build/mdgen/main_ghostty_5.zig @@ -1,12 +1,11 @@ const std = @import("std"); const gen = @import("mdgen.zig"); -pub fn main() !void { - var gpa: std.heap.GeneralPurposeAllocator(.{}) = .init; - const alloc = gpa.allocator(); +pub fn main(init: std.process.Init) !void { + const alloc = init.arena.allocator(); var buffer: [1024]u8 = undefined; - var stdout_writer = std.fs.File.stdout().writer(&buffer); + var stdout_writer = std.Io.File.stdout().writer(init.io, &buffer); const writer = &stdout_writer.interface; try gen.substitute(alloc, @embedFile("ghostty_5_header.md"), writer); try gen.genConfig(writer, false); diff --git a/vendor/libghostty-vt/src/build/uucode_config.zig b/vendor/libghostty-vt/src/build/uucode_config.zig index 2bb0d450..20c42551 100644 --- a/vendor/libghostty-vt/src/build/uucode_config.zig +++ b/vendor/libghostty-vt/src/build/uucode_config.zig @@ -1,109 +1,162 @@ const std = @import("std"); const assert = std.debug.assert; const config = @import("config.zig"); -const config_x = @import("config.x.zig"); -const d = config.default; -const wcwidth = config_x.wcwidth; -const grapheme_break_no_control = config_x.grapheme_break_no_control; const Allocator = std.mem.Allocator; -fn computeWidth( - alloc: std.mem.Allocator, - cp: u21, - data: anytype, - backing: anytype, - tracking: anytype, -) Allocator.Error!void { - _ = alloc; - _ = cp; - _ = backing; - _ = tracking; - - // This condition is needed as Ghostty currently has a singular concept for - // the `width` of a code point, while `uucode` splits the concept into - // `wcwidth_standalone` and `wcwidth_zero_in_grapheme`. The two cases where - // we want to use the `wcwidth_standalone` despite the code point occupying - // zero width in a grapheme (`wcwidth_zero_in_grapheme`) are emoji - // modifiers and prepend code points. For emoji modifiers we want to - // support displaying them in isolation as color patches, and if prepend - // characters were to be width 0 they would disappear from the output with - // Ghostty's current width 0 handling. Future work will take advantage of - // the new uucode `wcwidth_standalone` vs `wcwidth_zero_in_grapheme` split. - if (data.wcwidth_zero_in_grapheme and !data.is_emoji_modifier and data.grapheme_break_no_control != .prepend) { - data.width = 0; - } else { - data.width = @min(2, data.wcwidth_standalone); - } -} - -const width = config.Extension{ - .inputs = &.{ - "wcwidth_standalone", - "wcwidth_zero_in_grapheme", - "is_emoji_modifier", - "grapheme_break_no_control", +pub const fields = &config.mergeFields(config.fields, &.{ + .{ .name = "width", .type = u2 }, + .{ .name = "is_symbol", .type = bool }, +}); +pub const build_components = &config.mergeComponents(config.build_components, &.{ + .{ + .Impl = WidthComponent, + .inputs = &.{ + "wcwidth_standalone", + "wcwidth_zero_in_grapheme", + "is_emoji_modifier", + "grapheme_break_no_control", + }, + .fields = &.{"width"}, }, - .compute = &computeWidth, - .fields = &.{ - .{ .name = "width", .type = u2 }, + .{ + .Impl = IsSymbolComponent, + .inputs = &.{ "block", "general_category" }, + .fields = &.{"is_symbol"}, }, -}; +}); -fn computeIsSymbol( - alloc: Allocator, - cp: u21, - data: anytype, - backing: anytype, - tracking: anytype, -) Allocator.Error!void { - _ = alloc; - _ = cp; - _ = backing; - _ = tracking; - const block = data.block; - data.is_symbol = data.general_category == .other_private_use or - block == .arrows or - block == .dingbats or - block == .emoticons or - block == .miscellaneous_symbols or - block == .enclosed_alphanumerics or - block == .enclosed_alphanumeric_supplement or - block == .miscellaneous_symbols_and_pictographs or - block == .transport_and_map_symbols; -} - -const is_symbol = config.Extension{ - .inputs = &.{ "block", "general_category" }, - .compute = &computeIsSymbol, - .fields = &.{ - .{ .name = "is_symbol", .type = bool }, - }, -}; +pub const get_components: []const config.Component = &.{}; pub const tables = [_]config.Table{ .{ .name = "runtime", - .extensions = &.{}, .fields = &.{ - d.field("is_emoji_presentation"), - d.field("case_folding_full"), + "is_emoji_presentation", + "case_folding_full", }, }, .{ - .name = "buildtime", - .extensions = &.{ - wcwidth, - grapheme_break_no_control, - width, - is_symbol, + // Fields that libvaxis needs that aren't included in the `runtime` + // table. + .name = "libvaxis_only", + .fields = &.{ + "east_asian_width", + "general_category", + "grapheme_break", }, + }, + .{ + .name = "buildtime", + // Unpacked, because a packed table this size cannot be evaluated + // at comptime by Zig 0.16.0 and this one has to be, whether or not + // anything reads it. + // + // `uucode.get.FieldEnum` is built by walking `@TypeOf(tables)`, so + // instantiating anything that names a field -- `grapheme.Iterator`, + // among others -- materialises every table including this one. In a + // packed layout its rows are a `@bitCast` of a 32000 element `[_]u10`, + // and the compiler gives up on it two different ways depending on + // build options: + // + // tables.zig: error: unable to evaluate comptime expression + // thread panic: TODO implement writeToPackedMemory for more types + // + // Ghostty does not hit this, because its own uses of these fields go + // through the tables generated at build time rather than through + // `uucode.get`. A downstream pairing this config with libvaxis does: + // vaxis measures grapheme widths with `grapheme.Iterator`, so any + // program calling `Vaxis.render` fails to compile. + // + // The cost is the size of a table nothing reads at runtime. The fix + // belongs upstream in uucode or in the compiler; this is what makes + // the pairing build today. + .packing = .unpacked, .fields = &.{ - width.field("width"), - wcwidth.field("wcwidth_zero_in_grapheme"), - grapheme_break_no_control.field("grapheme_break_no_control"), - is_symbol.field("is_symbol"), - d.field("is_emoji_vs_base"), + "width", + "wcwidth_zero_in_grapheme", + "grapheme_break_no_control", + "is_symbol", + "is_emoji_vs_base", }, }, }; + +const WidthComponent = struct { + pub fn build( + comptime InputRow: type, + comptime Row: type, + allocator: std.mem.Allocator, + io: std.Io, + inputs: config.MultiSlice(InputRow), + rows: *config.MultiSlice(Row), + backing: anytype, + tracking: anytype, + ) !void { + _ = allocator; + _ = io; + _ = backing; + _ = tracking; + + rows.len = config.num_code_points; + const items = rows.items(.width); + const standalone = inputs.items(.wcwidth_standalone); + const zero_in_grapheme = inputs.items(.wcwidth_zero_in_grapheme); + const is_emoji_modifier = inputs.items(.is_emoji_modifier); + const grapheme_break_no_control = inputs.items(.grapheme_break_no_control); + + // This condition is needed as Ghostty currently has a singular concept for + // the `width` of a code point, while `uucode` splits the concept into + // `wcwidth_standalone` and `wcwidth_zero_in_grapheme`. The two cases where + // we want to use the `wcwidth_standalone` despite the code point occupying + // zero width in a grapheme (`wcwidth_zero_in_grapheme`) are emoji + // modifiers and prepend code points. For emoji modifiers we want to + // support displaying them in isolation as color patches, and if prepend + // characters were to be width 0 they would disappear from the output with + // Ghostty's current width 0 handling. Future work will take advantage of + // the new uucode `wcwidth_standalone` vs `wcwidth_zero_in_grapheme` split. + for (0..config.num_code_points) |i| { + if (zero_in_grapheme[i] and !is_emoji_modifier[i] and grapheme_break_no_control[i] != .prepend) { + items[i] = 0; + } else { + items[i] = @min(2, standalone[i]); + } + } + } +}; + +const IsSymbolComponent = struct { + pub fn build( + comptime InputRow: type, + comptime Row: type, + allocator: std.mem.Allocator, + io: std.Io, + inputs: config.MultiSlice(InputRow), + rows: *config.MultiSlice(Row), + backing: anytype, + tracking: anytype, + ) !void { + _ = allocator; + _ = io; + _ = backing; + _ = tracking; + + rows.len = config.num_code_points; + const items = rows.items(.is_symbol); + const block = inputs.items(.block); + const general_category = inputs.items(.general_category); + + for (0..config.num_code_points) |i| { + items[i] = + general_category[i] == .other_private_use or + block[i] == .arrows or + block[i] == .dingbats or + block[i] == .emoticons or + block[i] == .miscellaneous_symbols or + block[i] == .enclosed_alphanumerics or + block[i] == .enclosed_alphanumeric_supplement or + block[i] == .miscellaneous_symbols_and_pictographs or + block[i] == .transport_and_map_symbols; + } + } +}; diff --git a/vendor/libghostty-vt/src/build/wasm_patch_growable_table.zig b/vendor/libghostty-vt/src/build/wasm_patch_growable_table.zig index c40c0a9c..555734ab 100644 --- a/vendor/libghostty-vt/src/build/wasm_patch_growable_table.zig +++ b/vendor/libghostty-vt/src/build/wasm_patch_growable_table.zig @@ -12,15 +12,13 @@ const std = @import("std"); const testing = std.testing; const Allocator = std.mem.Allocator; -pub fn main() !void { +pub fn main(init: std.process.Init) !void { // This is a one-off patcher, so we leak all our memory on purpose // and let the OS clean it up when we exit. - var gpa: std.heap.GeneralPurposeAllocator(.{}) = .init; - const alloc = gpa.allocator(); + const alloc = init.arena.allocator(); // Parse args: program input output - const args = try std.process.argsAlloc(alloc); - defer std.process.argsFree(alloc, args); + const args = try init.minimal.args.toSlice(alloc); if (args.len != 3) { std.log.err("usage: wasm_growable_table ", .{}); std.process.exit(1); @@ -30,17 +28,18 @@ pub fn main() !void { // Patch the file. const output: []const u8 = try patchTableGrowable( alloc, - try std.fs.cwd().readFileAlloc( - alloc, + try std.Io.Dir.cwd().readFileAlloc( + init.io, args[1], - std.math.maxInt(usize), + alloc, + .unlimited, ), ); // Write our output - const out_file = try std.fs.cwd().createFile(args[2], .{}); - defer out_file.close(); - try out_file.writeAll(output); + const out_file = try std.Io.Dir.cwd().createFile(init.io, args[2], .{}); + defer out_file.close(init.io); + try out_file.writePositionalAll(init.io, output, 0); } /// Patch the WASM binary's table section to remove the maximum size diff --git a/vendor/libghostty-vt/src/build/webgen/main_actions.zig b/vendor/libghostty-vt/src/build/webgen/main_actions.zig index b0de6537..38cda3f7 100644 --- a/vendor/libghostty-vt/src/build/webgen/main_actions.zig +++ b/vendor/libghostty-vt/src/build/webgen/main_actions.zig @@ -1,9 +1,9 @@ const std = @import("std"); const helpgen_actions = @import("../../input/helpgen_actions.zig"); -pub fn main() !void { +pub fn main(init: std.process.Init) !void { var buffer: [2048]u8 = undefined; - var stdout_writer = std.fs.File.stdout().writer(&buffer); + var stdout_writer = std.Io.File.stdout().writer(init.io, &buffer); const stdout = &stdout_writer.interface; try helpgen_actions.generate(stdout, .markdown, true, std.heap.page_allocator); } diff --git a/vendor/libghostty-vt/src/build/webgen/main_commands.zig b/vendor/libghostty-vt/src/build/webgen/main_commands.zig index 65f14452..c895948d 100644 --- a/vendor/libghostty-vt/src/build/webgen/main_commands.zig +++ b/vendor/libghostty-vt/src/build/webgen/main_commands.zig @@ -2,9 +2,9 @@ const std = @import("std"); const Action = @import("../../cli/ghostty.zig").Action; const help_strings = @import("help_strings"); -pub fn main() !void { +pub fn main(init: std.process.Init) !void { var buffer: [2048]u8 = undefined; - var stdout_writer = std.fs.File.stdout().writer(&buffer); + var stdout_writer = std.Io.File.stdout().writer(init.io, &buffer); const stdout = &stdout_writer.interface; try genActions(stdout); } diff --git a/vendor/libghostty-vt/src/build/webgen/main_config.zig b/vendor/libghostty-vt/src/build/webgen/main_config.zig index 1363fadc..82ecc950 100644 --- a/vendor/libghostty-vt/src/build/webgen/main_config.zig +++ b/vendor/libghostty-vt/src/build/webgen/main_config.zig @@ -2,9 +2,9 @@ const std = @import("std"); const Config = @import("../../config/Config.zig"); const help_strings = @import("help_strings"); -pub fn main() !void { +pub fn main(init: std.process.Init) !void { var buffer: [2048]u8 = undefined; - var stdout_writer = std.fs.File.stdout().writer(&buffer); + var stdout_writer = std.Io.File.stdout().writer(init.io, &buffer); const stdout = &stdout_writer.interface; try genConfig(stdout); } diff --git a/vendor/libghostty-vt/src/cli/Pager.zig b/vendor/libghostty-vt/src/cli/Pager.zig index 247c998e..1523c885 100644 --- a/vendor/libghostty-vt/src/cli/Pager.zig +++ b/vendor/libghostty-vt/src/cli/Pager.zig @@ -6,29 +6,31 @@ //! Setting either env var to an empty string disables paging. //! If stdout is not a TTY, writes go directly to stdout. const Pager = @This(); +const builtin = @import("builtin"); const std = @import("std"); const Allocator = std.mem.Allocator; const internal_os = @import("../os/main.zig"); +const global = @import("../global.zig"); /// The pager child process, if one was spawned. child: ?std.process.Child = null, /// The buffered file writer used for both the pager pipe and direct /// stdout paths. -file_writer: std.fs.File.Writer = undefined, +file_writer: std.Io.File.Writer = undefined, /// Initialize the pager. If stdout is a TTY, this spawns the pager /// process. Otherwise, output goes directly to stdout. -pub fn init(alloc: Allocator) Pager { - return .{ .child = initPager(alloc) }; +pub fn init() Pager { + return .{ .child = initPager() }; } /// Writes to the pager process if available; otherwise, stdout. pub fn writer(self: *Pager, buffer: []u8) *std.Io.Writer { if (self.child) |child| { - self.file_writer = child.stdin.?.writer(buffer); + self.file_writer = child.stdin.?.writer(global.io(), buffer); } else { - self.file_writer = std.fs.File.stdout().writer(buffer); + self.file_writer = std.Io.File.stdout().writer(global.io(), buffer); } return &self.file_writer.interface; } @@ -40,45 +42,44 @@ pub fn deinit(self: *Pager) void { // pager sees EOF, then wait for it to exit. self.file_writer.interface.flush() catch {}; if (child.stdin) |stdin| { - stdin.close(); + stdin.close(global.io()); child.stdin = null; } - _ = child.wait() catch {}; + _ = child.wait(global.io()) catch {}; } self.* = undefined; } -fn initPager(alloc: Allocator) ?std.process.Child { - const stdout_file: std.fs.File = .stdout(); - if (!stdout_file.isTty()) return null; +fn initPager() ?std.process.Child { + const stdout_file: std.Io.File = .stdout(); + if (!(stdout_file.isTty(global.io()) catch return null)) return null; + + var env = global.environMap() catch return null; + defer env.deinit(); // Resolve the pager command: $GHOSTTY_PAGER > $PAGER > `less`. // An empty value for either env var disables paging. - const ghostty_var = internal_os.getenv(alloc, "GHOSTTY_PAGER") catch null; - defer if (ghostty_var) |v| v.deinit(alloc); - const pager_var = internal_os.getenv(alloc, "PAGER") catch null; - defer if (pager_var) |v| v.deinit(alloc); - + const ghostty_var = env.get("GHOSTTY_PAGER"); + const pager_var = env.get("PAGER"); const cmd: ?[]const u8 = cmd: { - if (ghostty_var) |v| break :cmd if (v.value.len > 0) v.value else null; - if (pager_var) |v| break :cmd if (v.value.len > 0) v.value else null; + if (ghostty_var) |v| break :cmd if (v.len > 0) v else null; + if (pager_var) |v| break :cmd if (v.len > 0) v else null; break :cmd "less"; }; if (cmd == null) return null; - var child: std.process.Child = .init(&.{cmd.?}, alloc); - child.stdin_behavior = .Pipe; - child.stdout_behavior = .Inherit; - child.stderr_behavior = .Inherit; - - child.spawn() catch return null; - return child; + return std.process.spawn(global.io(), .{ + .argv = &.{cmd.?}, + .stdin = .pipe, + .stdout = .inherit, + .stderr = .inherit, + }) catch null; } test "pager: non-tty" { - var pager: Pager = .init(std.testing.allocator); + var pager: Pager = .init(); defer pager.deinit(); try std.testing.expect(pager.child == null); } diff --git a/vendor/libghostty-vt/src/cli/action.zig b/vendor/libghostty-vt/src/cli/action.zig index 41173a9f..6cedaa09 100644 --- a/vendor/libghostty-vt/src/cli/action.zig +++ b/vendor/libghostty-vt/src/cli/action.zig @@ -11,8 +11,8 @@ pub const DetectError = error{ }; /// Detect the action from CLI args. -pub fn detectArgs(comptime E: type, alloc: Allocator) !?E { - var iter = try std.process.argsWithAllocator(alloc); +pub fn detectArgs(comptime E: type, alloc: Allocator, args: std.process.Args) !?E { + var iter = try args.iterateAllocator(alloc); defer iter.deinit(); return try detectIter(E, &iter); } @@ -83,7 +83,7 @@ test "detect direct match" { const alloc = testing.allocator; const Enum = enum { foo, bar, baz }; - var iter = try std.process.ArgIteratorGeneral(.{}).init( + var iter = try std.process.Args.IteratorGeneral(.{}).init( alloc, "+foo", ); @@ -97,7 +97,7 @@ test "detect invalid match" { const alloc = testing.allocator; const Enum = enum { foo, bar, baz }; - var iter = try std.process.ArgIteratorGeneral(.{}).init( + var iter = try std.process.Args.IteratorGeneral(.{}).init( alloc, "+invalid", ); @@ -113,7 +113,7 @@ test "detect multiple actions" { const alloc = testing.allocator; const Enum = enum { foo, bar, baz }; - var iter = try std.process.ArgIteratorGeneral(.{}).init( + var iter = try std.process.Args.IteratorGeneral(.{}).init( alloc, "+foo +bar", ); @@ -129,7 +129,7 @@ test "detect no match" { const alloc = testing.allocator; const Enum = enum { foo, bar, baz }; - var iter = try std.process.ArgIteratorGeneral(.{}).init( + var iter = try std.process.Args.IteratorGeneral(.{}).init( alloc, "--some-flag", ); @@ -154,7 +154,7 @@ test "detect special case action" { }; { - var iter = try std.process.ArgIteratorGeneral(.{}).init( + var iter = try std.process.Args.IteratorGeneral(.{}).init( alloc, "--special +bar", ); @@ -164,7 +164,7 @@ test "detect special case action" { } { - var iter = try std.process.ArgIteratorGeneral(.{}).init( + var iter = try std.process.Args.IteratorGeneral(.{}).init( alloc, "+bar --special", ); @@ -174,7 +174,7 @@ test "detect special case action" { } { - var iter = try std.process.ArgIteratorGeneral(.{}).init( + var iter = try std.process.Args.IteratorGeneral(.{}).init( alloc, "+bar", ); @@ -200,7 +200,7 @@ test "detect special case fallback" { }; { - var iter = try std.process.ArgIteratorGeneral(.{}).init( + var iter = try std.process.Args.IteratorGeneral(.{}).init( alloc, "--special", ); @@ -210,7 +210,7 @@ test "detect special case fallback" { } { - var iter = try std.process.ArgIteratorGeneral(.{}).init( + var iter = try std.process.Args.IteratorGeneral(.{}).init( alloc, "+bar --special", ); @@ -220,7 +220,7 @@ test "detect special case fallback" { } { - var iter = try std.process.ArgIteratorGeneral(.{}).init( + var iter = try std.process.Args.IteratorGeneral(.{}).init( alloc, "--special +bar", ); @@ -246,7 +246,7 @@ test "detect special case abort_if_no_action" { }; { - var iter = try std.process.ArgIteratorGeneral(.{}).init( + var iter = try std.process.Args.IteratorGeneral(.{}).init( alloc, "-e", ); @@ -256,7 +256,7 @@ test "detect special case abort_if_no_action" { } { - var iter = try std.process.ArgIteratorGeneral(.{}).init( + var iter = try std.process.Args.IteratorGeneral(.{}).init( alloc, "+foo -e", ); @@ -266,7 +266,7 @@ test "detect special case abort_if_no_action" { } { - var iter = try std.process.ArgIteratorGeneral(.{}).init( + var iter = try std.process.Args.IteratorGeneral(.{}).init( alloc, "-e +bar", ); diff --git a/vendor/libghostty-vt/src/cli/args.zig b/vendor/libghostty-vt/src/cli/args.zig index bd5060d6..38616fc0 100644 --- a/vendor/libghostty-vt/src/cli/args.zig +++ b/vendor/libghostty-vt/src/cli/args.zig @@ -489,18 +489,13 @@ pub fn parseTaggedUnion(comptime T: type, alloc: Allocator, v: []const u8) !T { // We need to create a struct that looks like this union field. // This lets us use parseIntoField as if its a dedicated struct. - const Target = @Type(.{ .@"struct" = .{ - .layout = .auto, - .fields = &.{.{ - .name = field.name, - .type = field.type, - .default_value_ptr = null, - .is_comptime = false, - .alignment = @alignOf(field.type), - }}, - .decls = &.{}, - .is_tuple = false, - } }); + const Target = @Struct( + .auto, + null, + &.{field.name}, + &.{field.type}, + &.{.{ .@"align" = @alignOf(field.type) }}, + ); // Parse the value into the struct var t: Target = undefined; @@ -677,7 +672,7 @@ test "parse: simple" { } = .{}; defer if (data._arena) |arena| arena.deinit(); - var iter = try std.process.ArgIteratorGeneral(.{}).init( + var iter = try std.process.Args.IteratorGeneral(.{}).init( testing.allocator, "--a=42 --b --b-f=false", ); @@ -689,7 +684,7 @@ test "parse: simple" { try testing.expect(!data.@"b-f"); // Reparsing works - var iter2 = try std.process.ArgIteratorGeneral(.{}).init( + var iter2 = try std.process.Args.IteratorGeneral(.{}).init( testing.allocator, "--a=84", ); @@ -711,7 +706,7 @@ test "parse: quoted value" { } = .{}; defer if (data._arena) |arena| arena.deinit(); - var iter = try std.process.ArgIteratorGeneral(.{}).init( + var iter = try std.process.Args.IteratorGeneral(.{}).init( testing.allocator, "--a=\"42\" --b=\"hello!\"", ); @@ -731,7 +726,7 @@ test "parse: empty value resets to default" { } = .{}; defer if (data._arena) |arena| arena.deinit(); - var iter = try std.process.ArgIteratorGeneral(.{}).init( + var iter = try std.process.Args.IteratorGeneral(.{}).init( testing.allocator, "--a= --b=", ); @@ -750,7 +745,7 @@ test "parse: positional arguments are invalid" { } = .{}; defer if (data._arena) |arena| arena.deinit(); - var iter = try std.process.ArgIteratorGeneral(.{}).init( + var iter = try std.process.Args.IteratorGeneral(.{}).init( testing.allocator, "--a=84 what", ); @@ -774,7 +769,7 @@ test "parse: diagnostic tracking" { } = .{}; defer if (data._arena) |arena| arena.deinit(); - var iter = try std.process.ArgIteratorGeneral(.{}).init( + var iter = try std.process.Args.IteratorGeneral(.{}).init( testing.allocator, "--what --a=42", ); @@ -858,7 +853,7 @@ test "parse: compatibility handler" { } = .{}; defer if (data._arena) |arena| arena.deinit(); - var iter = try std.process.ArgIteratorGeneral(.{}).init( + var iter = try std.process.Args.IteratorGeneral(.{}).init( testing.allocator, "--a=yuh", ); @@ -884,7 +879,7 @@ test "parse: compatibility renamed" { } = .{}; defer if (data._arena) |arena| arena.deinit(); - var iter = try std.process.ArgIteratorGeneral(.{}).init( + var iter = try std.process.Args.IteratorGeneral(.{}).init( testing.allocator, "--old=true --b=true", ); @@ -1363,8 +1358,11 @@ pub fn ArgsIterator(comptime Iterator: type) type { } /// Create an args iterator for the process args. This will skip argv0. -pub fn argsIterator(alloc_gpa: Allocator) internal_os.args.ArgIterator.InitError!ArgsIterator(internal_os.args.ArgIterator) { - var iter = try internal_os.args.iterator(alloc_gpa); +pub fn argsIterator( + alloc_gpa: Allocator, + args: std.process.Args, +) std.process.Args.Iterator.InitError!ArgsIterator(std.process.Args.Iterator) { + var iter: std.process.Args.Iterator = try .initAllocator(args, alloc_gpa); errdefer iter.deinit(); _ = iter.next(); // skip argv0 return .{ .iterator = iter }; @@ -1373,7 +1371,7 @@ pub fn argsIterator(alloc_gpa: Allocator) internal_os.args.ArgIterator.InitError test "ArgsIterator" { const testing = std.testing; - const child = try std.process.ArgIteratorGeneral(.{}).init( + const child = try std.process.Args.IteratorGeneral(.{}).init( testing.allocator, "--what +list-things --a=42", ); @@ -1456,8 +1454,15 @@ pub const LineIterator = struct { entry = entry[0..trim.len]; } - // Ignore blank lines and comments - if (entry.len == 0 or entry[0] == '#') continue; + // Ignore blank lines and comments. If this line consumed the + // remaining buffer, refill before the loop condition checks for + // more data. + if (entry.len == 0 or entry[0] == '#') { + if (self.r.seek == self.r.end) { + self.r.fillMore() catch {}; + } + continue; + } break entry; } else return null; @@ -1625,3 +1630,27 @@ test "LineIterator with buffered and primed reader" { try testing.expectEqual(@as(?[]const u8, null), iter.next()); try testing.expectEqual(@as(?[]const u8, null), iter.next()); } + +test "LineIterator refills after ignored line at buffer boundary" { + const testing = std.testing; + + { + var f: std.Io.Reader = .fixed("#\nA\n"); + var buf: [2]u8 = undefined; + var r = f.limited(.unlimited, &buf); + var iter: LineIterator = .init(&r.interface); + + try testing.expectEqualStrings("--A", iter.next().?); + try testing.expectEqual(@as(?[]const u8, null), iter.next()); + } + + { + var f: std.Io.Reader = .fixed("\nA\n"); + var buf: [1]u8 = undefined; + var r = f.limited(.unlimited, &buf); + var iter: LineIterator = .init(&r.interface); + + try testing.expectEqualStrings("--A", iter.next().?); + try testing.expectEqual(@as(?[]const u8, null), iter.next()); + } +} diff --git a/vendor/libghostty-vt/src/cli/boo.zig b/vendor/libghostty-vt/src/cli/boo.zig index 2834eadb..a33d56ae 100644 --- a/vendor/libghostty-vt/src/cli/boo.zig +++ b/vendor/libghostty-vt/src/cli/boo.zig @@ -3,6 +3,7 @@ const builtin = @import("builtin"); const args = @import("args.zig"); const Action = @import("ghostty.zig").Action; const Allocator = std.mem.Allocator; +const global = @import("../global.zig"); const vaxis = @import("vaxis"); const framedata = @import("framedata").compressed; @@ -173,6 +174,9 @@ const Boo = struct { /// The `boo` command is used to display the animation from the Ghostty website in the terminal pub fn run(gpa: Allocator) !u8 { + var env_map = try global.environMap(); + defer env_map.deinit(); + // Disable on non-desktop systems. switch (builtin.os.tag) { .windows, .macos, .linux, .freebsd => {}, @@ -183,7 +187,7 @@ pub fn run(gpa: Allocator) !u8 { defer opts.deinit(); { - var iter = try args.argsIterator(gpa); + var iter = try args.argsIterator(gpa, global.args()); defer iter.deinit(); try args.parse(Options, gpa, &opts, &iter); } @@ -194,7 +198,7 @@ pub fn run(gpa: Allocator) !u8 { gpa.free(decompressed_data); } - var app = try vxfw.App.init(gpa); + var app = try vxfw.App.init(global.io(), gpa, &env_map, &.{}); defer app.deinit(); var boo: Boo = undefined; diff --git a/vendor/libghostty-vt/src/cli/crash_report.zig b/vendor/libghostty-vt/src/cli/crash_report.zig index f0940fda..e189ddae 100644 --- a/vendor/libghostty-vt/src/cli/crash_report.zig +++ b/vendor/libghostty-vt/src/cli/crash_report.zig @@ -4,6 +4,7 @@ const args = @import("args.zig"); const Action = @import("ghostty.zig").Action; const Config = @import("../config.zig").Config; const crash = @import("../crash/main.zig"); +const global = @import("../global.zig"); pub const Options = struct { pub fn deinit(self: Options) void { @@ -33,32 +34,36 @@ pub fn run(alloc_gpa: Allocator) !u8 { defer opts.deinit(); { - var iter = try args.argsIterator(alloc_gpa); + var iter = try args.argsIterator(alloc_gpa, global.args()); defer iter.deinit(); try args.parse(Options, alloc_gpa, &opts, &iter); } var buffer: [1024]u8 = undefined; - var stdout_file: std.fs.File = .stdout(); - var stdout_writer = stdout_file.writer(&buffer); + var stdout_file: std.Io.File = .stdout(); + var stdout_writer = stdout_file.writer(global.io(), &buffer); const stdout = &stdout_writer.interface; - const result = runInner(alloc, &stdout_file, stdout); + var environ_map = try global.environMap(); + defer environ_map.deinit(); + const result = runInner(global.io(), alloc, &environ_map, &stdout_file, stdout); stdout.flush() catch {}; return result; } fn runInner( + io: std.Io, alloc: Allocator, - stdout_file: *std.fs.File, + environ_map: *const std.process.Environ.Map, + stdout_file: *std.Io.File, stdout: *std.Io.Writer, ) !u8 { - const crash_dir = try crash.defaultDir(alloc); + const crash_dir = try crash.defaultDir(io, alloc, environ_map); var reports: std.ArrayList(crash.Report) = .empty; errdefer reports.deinit(alloc); - var it = try crash_dir.iterator(); - while (try it.next()) |report| try reports.append(alloc, .{ + var it = try crash_dir.iterator(io); + while (try it.next(io)) |report| try reports.append(alloc, .{ .name = try alloc.dupe(u8, report.name), .mtime = report.mtime, }); @@ -66,7 +71,7 @@ fn runInner( // If we have no reports, then we're done. If we have a tty then we // print a message, otherwise we do nothing. if (reports.items.len == 0) { - if (std.posix.isatty(stdout_file.handle)) { + if (try stdout_file.isTty(global.io())) { try stdout.writeAll("No crash reports! 👻\n"); } return 0; @@ -76,7 +81,7 @@ fn runInner( for (reports.items) |report| { var buf: [128]u8 = undefined; - const now = std.time.nanoTimestamp(); + const now = std.Io.Timestamp.now(global.io(), .real).toNanoseconds(); const diff = now - report.mtime; const since = if (diff <= 0) "now" else s: { const d = Config.Duration{ .duration = @intCast(diff) }; diff --git a/vendor/libghostty-vt/src/cli/diagnostics.zig b/vendor/libghostty-vt/src/cli/diagnostics.zig index 7f4dcc45..0e4048a0 100644 --- a/vendor/libghostty-vt/src/cli/diagnostics.zig +++ b/vendor/libghostty-vt/src/cli/diagnostics.zig @@ -93,7 +93,7 @@ pub const Location = union(enum) { /// and potentially in the future structure them differently. pub const DiagnosticList = struct { /// The list of diagnostics. - list: std.ArrayListUnmanaged(Diagnostic) = .{}, + list: std.ArrayList(Diagnostic) = .empty, /// Precomputed data for diagnostics. This is used specifically /// when we build libghostty so that we can precompute the messages @@ -111,7 +111,7 @@ pub const DiagnosticList = struct { }; const Precompute = if (precompute_enabled) struct { - messages: std.ArrayListUnmanaged([:0]const u8) = .{}, + messages: std.ArrayList([:0]const u8) = .empty, pub fn clone( self: *const Precompute, diff --git a/vendor/libghostty-vt/src/cli/edit_config.zig b/vendor/libghostty-vt/src/cli/edit_config.zig index c08651a0..cc6aec6b 100644 --- a/vendor/libghostty-vt/src/cli/edit_config.zig +++ b/vendor/libghostty-vt/src/cli/edit_config.zig @@ -7,6 +7,7 @@ const Action = @import("ghostty.zig").Action; const configpkg = @import("../config.zig"); const internal_os = @import("../os/main.zig"); const Config = configpkg.Config; +const global = @import("../global.zig"); pub const Options = struct { pub fn deinit(self: Options) void { @@ -48,14 +49,14 @@ pub fn run(alloc: Allocator) !u8 { // critical where setting up the defer cleanup is a problem. var buffer: [1024]u8 = undefined; - var stderr_writer = std.fs.File.stderr().writer(&buffer); + var stderr_writer = std.Io.File.stderr().writer(global.io(), &buffer); const stderr = &stderr_writer.interface; var opts: Options = .{}; defer opts.deinit(); { - var iter = try args.argsIterator(alloc); + var iter = try args.argsIterator(alloc, global.args()); defer iter.deinit(); try args.parse(Options, alloc, &opts, &iter); } @@ -67,6 +68,12 @@ pub fn run(alloc: Allocator) !u8 { } fn runInner(alloc: Allocator, stderr: *std.Io.Writer) !u8 { + // 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); + // 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); @@ -82,99 +89,74 @@ fn runInner(alloc: Allocator, stderr: *std.Io.Writer) !u8 { \\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; } - const command = command: { - var buffer: std.io.Writer.Allocating = .init(alloc); - defer buffer.deinit(); - const writer = &buffer.writer; - try writer.writeAll(editor); - try writer.writeByte(' '); - { - var sh: internal_os.ShellEscapeWriter = .init(writer); - try sh.writer.writeAll(path); - try sh.writer.flush(); + const command = internal_os.getConfigEditCommand(alloc, path, .{ .default_editor = .failure }) catch |err| { + switch (err) { + error.NoEditorConfigured => { + 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; + }, + error.WriteFailed, + error.OutOfMemory, + => |e| return e, } - try writer.flush(); - break :command try buffer.toOwnedSliceSentinel(0); }; defer alloc.free(command); - // 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 err = std.posix.execvpeZ( + // Run/replace process (using execve) + const argv = [_:null]?[*:0]const u8{ "/bin/sh", - &.{ "/bin/sh", "-c", command }, - std.c.environ, - ); + "-c", + command.ptr, + }; + const envp = std.c.environ; + const err = std.posix.errno(std.posix.system.execve(argv[0].?, &argv, envp)); // If we reached this point then exec failed. try stderr.print( - \\Failed to execute the editor. Error code={}. + \\Failed to execute the editor (E{t}). \\ \\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} + \\Command: {s} \\ - , .{ err, editor, path }); + , .{ + err, + command, + }); + // Output the path using the OSC8 sequence so that it is linked. + try stderr.print( + "Path: \x1b]8;;file://{s}\x1b\\{s}\x1b]8;;\x1b\\\n", + .{ path, path }, + ); return 1; } diff --git a/vendor/libghostty-vt/src/cli/explain_config.zig b/vendor/libghostty-vt/src/cli/explain_config.zig index 4f034afe..6f03256d 100644 --- a/vendor/libghostty-vt/src/cli/explain_config.zig +++ b/vendor/libghostty-vt/src/cli/explain_config.zig @@ -7,6 +7,7 @@ const Config = @import("../config/Config.zig"); const ConfigKey = @import("../config/key.zig").Key; const KeybindAction = @import("../input/Binding.zig").Action; const Pager = @import("Pager.zig"); +const global = @import("../global.zig"); pub const Options = struct { /// The config option to explain. For example: @@ -51,7 +52,7 @@ pub fn run(alloc: Allocator) !u8 { var positional: ?[]const u8 = null; var no_pager: bool = false; - var iter = try args.argsIterator(alloc); + var iter = try args.argsIterator(alloc, global.args()); defer iter.deinit(); defer if (option_name) |s| alloc.free(s); defer if (keybind_name) |s| alloc.free(s); @@ -75,9 +76,9 @@ pub fn run(alloc: Allocator) !u8 { // respective lookup. A bare positional argument tries config // options first, then keybind actions as a fallback. const name = keybind_name orelse option_name orelse positional orelse { - var stderr: std.fs.File = .stderr(); + var stderr: std.Io.File = .stderr(); var buffer: [4096]u8 = undefined; - var stderr_writer = stderr.writer(&buffer); + var stderr_writer = stderr.writer(global.io(), &buffer); try stderr_writer.interface.writeAll("Usage: ghostty +explain-config