From 1fdbb8c912231bdbe039614a70f10772a3e50d23 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 14 Aug 2026 21:02:46 -0700 Subject: [PATCH] libghostty: -Dvt-features to compile out unused features This introduces a `-Dvt-features` build option for libghostty-vt that compiles out optional feature areas, primarily so size-conscious embedders (e.g. wasm) can significantly trim the binary. The flag is similar to `-Dcpu`, `+feature` or `feature` to enable it, `-feature` to disable, magic word `all` to turn all features on or off. Example: `-Dvt-features=-all,+render-state` builds only the render state API. ### Sizes | Build | Bytes | Brotli | |---|---|---| | default (all features) | 876,500 | 218,309 | | web interactive (`-all,+render-state,+input-encode,+selection,+color,+grid-introspection`) | 661,119 | 168,994 | | read-only viewer (`-all,+render-state`) | 537,441 | 132,858 | | bare VT core (`-all`) | 515,422 | 125,756 | | xterm.js browser bundle (incl. renderers) | 488,663 | 99,311 | | @xterm/headless | 182,672 | 39,651 | Note: xterm versions are stable as of this commit. --- .github/workflows/test.yml | 47 +++++ PACKAGING.md | 21 ++ build.zig | 6 + src/build/Config.zig | 34 ++++ src/lib_vt.zig | 315 ++++++++++++++++-------------- src/terminal/Terminal.zig | 2 + src/terminal/apc.zig | 60 ++++-- src/terminal/build_options.zig | 254 ++++++++++++++++++++++-- src/terminal/c/kitty_graphics.zig | 2 + src/terminal/c/terminal.zig | 2 + src/terminal/stream_terminal.zig | 4 +- 11 files changed, 570 insertions(+), 177 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d73672a0f..e45793f22 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -95,6 +95,7 @@ jobs: - build-flatpak - build-libghostty-vt - build-libghostty-vt-wasm + - build-libghostty-vt-features - build-libghostty-vt-android - build-libghostty-vt-macos - build-libghostty-vt-windows @@ -726,6 +727,52 @@ jobs: echo "Verified ${artifact} requires simd128" done + # Compile-only checks for the -Dvt-features flags so that future changes + # don't regress any feature combination. + build-libghostty-vt-features: + runs-on: namespace-profile-ghostty-sm + needs: test + env: + ZIG_LOCAL_CACHE_DIR: /zig/local-cache + ZIG_GLOBAL_CACHE_DIR: /zig/global-cache + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup Cache + uses: namespacelabs/nscloud-cache-action@c5f8dab7560444c4bf8dbc64f1b203431873c547 # v1.6.1 + with: + path: | + /nix + /zig + + # Install Nix and use that to run our tests so our environment matches exactly. + - uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0 + with: + nix_path: nixpkgs=channel:nixos-unstable + - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 + with: + name: ghostty + authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" + + - name: Compile feature flag matrix + run: | + features="snapshot formatter selection render_state input_encode color grid_introspection glyph_protocol kitty_graphics" + configs="-all" + for f in $features; do configs="$configs -all,+$f"; done + + for cfg in $configs; do + echo "::group::-Dvt-features=$cfg" + # The wasm lib build compiles the C API export block; the + # test build compiles every module test without running any. + nix develop -c zig build -Demit-lib-vt \ + "-Dvt-features=$cfg" \ + -Dtarget=wasm32-freestanding + nix develop -c zig build test-lib-vt-build \ + "-Dvt-features=$cfg" + echo "::endgroup::" + done + # lib-vt requires macOS runner for macOS/iOS builds because it requires the `apple_sdk` path build-libghostty-vt-macos: strategy: diff --git a/PACKAGING.md b/PACKAGING.md index ec2ef3dee..8cb422517 100644 --- a/PACKAGING.md +++ b/PACKAGING.md @@ -141,6 +141,27 @@ Some notes for packaging the wasm module: a large performance win for VT parsing. If you target an unusual runtime without SIMD support, opt out with `-Dcpu=generic`. +- Optional feature areas can be compiled out with `-Dvt-features` to + significantly reduce binary size. The flag takes comma-separated + modifications applied to the default all-enabled feature set, + `-Dcpu`-style: `+feature` (or bare `feature`) enables, `-feature` + disables, and the special name `all` refers to every feature. Hyphens + and underscores are interchangeable in feature names. For example, a + read-only terminal viewer only needs the render state API: + + ```sh + zig build -Demit-lib-vt -Dtarget=wasm32-freestanding \ + -Doptimize=ReleaseSmall -Dvt-features=-all,+render-state + ``` + + This roughly halves the compressed module size versus the default + build. An interactive terminal typically wants + `-Dvt-features=-all,+render-state,+input-encode,+selection,+color`. + Disabled features drop both their C API exports and any escape + sequence handling (the sequences are still consumed and safely + ignored). See the `Features` struct in `src/terminal/build_options.zig` + for the full list of features and what each one covers. + - `ReleaseSmall` is the recommended optimization mode for the web. Running the result through [Binaryen's](https://github.com/WebAssembly/binaryen) `wasm-opt -O3` shrinks it by roughly a further 10% without hurting diff --git a/build.zig b/build.zig index acf19ac91..1c56cadf8 100644 --- a/build.zig +++ b/build.zig @@ -69,6 +69,10 @@ pub fn build(b: *std.Build) !void { "test-lib-vt", "Run libghostty-vt tests", ); + const test_lib_vt_build_step = b.step( + "test-lib-vt-build", + "Build libghostty-vt tests without running them (compile check)", + ); const test_valgrind_step = b.step( "test-valgrind", "Run tests under valgrind", @@ -329,6 +333,7 @@ pub fn build(b: *std.Build) !void { }); const mod_vt_test_run = b.addRunArtifact(mod_vt_test); test_lib_vt_step.dependOn(&mod_vt_test_run.step); + test_lib_vt_build_step.dependOn(&mod_vt_test.step); const mod_vt_c_test = b.addTest(.{ .root_module = mod.vt_c, @@ -336,6 +341,7 @@ pub fn build(b: *std.Build) !void { }); const mod_vt_c_test_run = b.addRunArtifact(mod_vt_c_test); test_lib_vt_step.dependOn(&mod_vt_c_test_run.step); + test_lib_vt_build_step.dependOn(&mod_vt_c_test.step); } // Tests (skip when building libghostty-vt) diff --git a/src/build/Config.zig b/src/build/Config.zig index 09ab1a915..c90f3781b 100644 --- a/src/build/Config.zig +++ b/src/build/Config.zig @@ -62,6 +62,10 @@ 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, @@ -421,6 +425,30 @@ pub fn init(b: *std.Build, appVersion: []const u8, libVersion: []const u8) !Conf 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, "emit-exe", @@ -684,6 +712,12 @@ pub fn terminalOptions( .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, diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 3b66930f2..f7ce5b386 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -180,44 +180,50 @@ comptime { _ = @import("quirks_memset.zig"); const c = terminal.c_api; - @export(&c.key_event_new, .{ .name = "ghostty_key_event_new" }); - @export(&c.key_event_free, .{ .name = "ghostty_key_event_free" }); - @export(&c.key_event_set_action, .{ .name = "ghostty_key_event_set_action" }); - @export(&c.key_event_get_action, .{ .name = "ghostty_key_event_get_action" }); - @export(&c.key_event_set_key, .{ .name = "ghostty_key_event_set_key" }); - @export(&c.key_event_get_key, .{ .name = "ghostty_key_event_get_key" }); - @export(&c.key_event_set_mods, .{ .name = "ghostty_key_event_set_mods" }); - @export(&c.key_event_get_mods, .{ .name = "ghostty_key_event_get_mods" }); - @export(&c.key_event_set_consumed_mods, .{ .name = "ghostty_key_event_set_consumed_mods" }); - @export(&c.key_event_get_consumed_mods, .{ .name = "ghostty_key_event_get_consumed_mods" }); - @export(&c.key_event_set_composing, .{ .name = "ghostty_key_event_set_composing" }); - @export(&c.key_event_get_composing, .{ .name = "ghostty_key_event_get_composing" }); - @export(&c.key_event_set_utf8, .{ .name = "ghostty_key_event_set_utf8" }); - @export(&c.key_event_get_utf8, .{ .name = "ghostty_key_event_get_utf8" }); - @export(&c.key_event_set_unshifted_codepoint, .{ .name = "ghostty_key_event_set_unshifted_codepoint" }); - @export(&c.key_event_get_unshifted_codepoint, .{ .name = "ghostty_key_event_get_unshifted_codepoint" }); - @export(&c.key_encoder_new, .{ .name = "ghostty_key_encoder_new" }); - @export(&c.key_encoder_free, .{ .name = "ghostty_key_encoder_free" }); - @export(&c.key_encoder_setopt, .{ .name = "ghostty_key_encoder_setopt" }); - @export(&c.key_encoder_setopt_from_terminal, .{ .name = "ghostty_key_encoder_setopt_from_terminal" }); - @export(&c.key_encoder_encode, .{ .name = "ghostty_key_encoder_encode" }); - @export(&c.mouse_event_new, .{ .name = "ghostty_mouse_event_new" }); - @export(&c.mouse_event_free, .{ .name = "ghostty_mouse_event_free" }); - @export(&c.mouse_event_set_action, .{ .name = "ghostty_mouse_event_set_action" }); - @export(&c.mouse_event_get_action, .{ .name = "ghostty_mouse_event_get_action" }); - @export(&c.mouse_event_set_button, .{ .name = "ghostty_mouse_event_set_button" }); - @export(&c.mouse_event_clear_button, .{ .name = "ghostty_mouse_event_clear_button" }); - @export(&c.mouse_event_get_button, .{ .name = "ghostty_mouse_event_get_button" }); - @export(&c.mouse_event_set_mods, .{ .name = "ghostty_mouse_event_set_mods" }); - @export(&c.mouse_event_get_mods, .{ .name = "ghostty_mouse_event_get_mods" }); - @export(&c.mouse_event_set_position, .{ .name = "ghostty_mouse_event_set_position" }); - @export(&c.mouse_event_get_position, .{ .name = "ghostty_mouse_event_get_position" }); - @export(&c.mouse_encoder_new, .{ .name = "ghostty_mouse_encoder_new" }); - @export(&c.mouse_encoder_free, .{ .name = "ghostty_mouse_encoder_free" }); - @export(&c.mouse_encoder_setopt, .{ .name = "ghostty_mouse_encoder_setopt" }); - @export(&c.mouse_encoder_setopt_from_terminal, .{ .name = "ghostty_mouse_encoder_setopt_from_terminal" }); - @export(&c.mouse_encoder_reset, .{ .name = "ghostty_mouse_encoder_reset" }); - @export(&c.mouse_encoder_encode, .{ .name = "ghostty_mouse_encoder_encode" }); + const features = terminal.options; + if (features.input_encode) { + @export(&c.key_event_new, .{ .name = "ghostty_key_event_new" }); + @export(&c.key_event_free, .{ .name = "ghostty_key_event_free" }); + @export(&c.key_event_set_action, .{ .name = "ghostty_key_event_set_action" }); + @export(&c.key_event_get_action, .{ .name = "ghostty_key_event_get_action" }); + @export(&c.key_event_set_key, .{ .name = "ghostty_key_event_set_key" }); + @export(&c.key_event_get_key, .{ .name = "ghostty_key_event_get_key" }); + @export(&c.key_event_set_mods, .{ .name = "ghostty_key_event_set_mods" }); + @export(&c.key_event_get_mods, .{ .name = "ghostty_key_event_get_mods" }); + @export(&c.key_event_set_consumed_mods, .{ .name = "ghostty_key_event_set_consumed_mods" }); + @export(&c.key_event_get_consumed_mods, .{ .name = "ghostty_key_event_get_consumed_mods" }); + @export(&c.key_event_set_composing, .{ .name = "ghostty_key_event_set_composing" }); + @export(&c.key_event_get_composing, .{ .name = "ghostty_key_event_get_composing" }); + @export(&c.key_event_set_utf8, .{ .name = "ghostty_key_event_set_utf8" }); + @export(&c.key_event_get_utf8, .{ .name = "ghostty_key_event_get_utf8" }); + @export(&c.key_event_set_unshifted_codepoint, .{ .name = "ghostty_key_event_set_unshifted_codepoint" }); + @export(&c.key_event_get_unshifted_codepoint, .{ .name = "ghostty_key_event_get_unshifted_codepoint" }); + @export(&c.key_encoder_new, .{ .name = "ghostty_key_encoder_new" }); + @export(&c.key_encoder_free, .{ .name = "ghostty_key_encoder_free" }); + @export(&c.key_encoder_setopt, .{ .name = "ghostty_key_encoder_setopt" }); + @export(&c.key_encoder_setopt_from_terminal, .{ .name = "ghostty_key_encoder_setopt_from_terminal" }); + @export(&c.key_encoder_encode, .{ .name = "ghostty_key_encoder_encode" }); + @export(&c.focus_encode, .{ .name = "ghostty_focus_encode" }); + @export(&c.paste_is_safe, .{ .name = "ghostty_paste_is_safe" }); + @export(&c.paste_encode, .{ .name = "ghostty_paste_encode" }); + @export(&c.mouse_event_new, .{ .name = "ghostty_mouse_event_new" }); + @export(&c.mouse_event_free, .{ .name = "ghostty_mouse_event_free" }); + @export(&c.mouse_event_set_action, .{ .name = "ghostty_mouse_event_set_action" }); + @export(&c.mouse_event_get_action, .{ .name = "ghostty_mouse_event_get_action" }); + @export(&c.mouse_event_set_button, .{ .name = "ghostty_mouse_event_set_button" }); + @export(&c.mouse_event_clear_button, .{ .name = "ghostty_mouse_event_clear_button" }); + @export(&c.mouse_event_get_button, .{ .name = "ghostty_mouse_event_get_button" }); + @export(&c.mouse_event_set_mods, .{ .name = "ghostty_mouse_event_set_mods" }); + @export(&c.mouse_event_get_mods, .{ .name = "ghostty_mouse_event_get_mods" }); + @export(&c.mouse_event_set_position, .{ .name = "ghostty_mouse_event_set_position" }); + @export(&c.mouse_event_get_position, .{ .name = "ghostty_mouse_event_get_position" }); + @export(&c.mouse_encoder_new, .{ .name = "ghostty_mouse_encoder_new" }); + @export(&c.mouse_encoder_free, .{ .name = "ghostty_mouse_encoder_free" }); + @export(&c.mouse_encoder_setopt, .{ .name = "ghostty_mouse_encoder_setopt" }); + @export(&c.mouse_encoder_setopt_from_terminal, .{ .name = "ghostty_mouse_encoder_setopt_from_terminal" }); + @export(&c.mouse_encoder_reset, .{ .name = "ghostty_mouse_encoder_reset" }); + @export(&c.mouse_encoder_encode, .{ .name = "ghostty_mouse_encoder_encode" }); + } @export(&c.osc_new, .{ .name = "ghostty_osc_new" }); @export(&c.osc_free, .{ .name = "ghostty_osc_free" }); @export(&c.osc_next, .{ .name = "ghostty_osc_next" }); @@ -226,10 +232,7 @@ comptime { @export(&c.osc_command_type, .{ .name = "ghostty_osc_command_type" }); @export(&c.osc_command_data, .{ .name = "ghostty_osc_command_data" }); @export(&c.color_scheme_report_encode, .{ .name = "ghostty_color_scheme_report_encode" }); - @export(&c.focus_encode, .{ .name = "ghostty_focus_encode" }); @export(&c.mode_report_encode, .{ .name = "ghostty_mode_report_encode" }); - @export(&c.paste_is_safe, .{ .name = "ghostty_paste_is_safe" }); - @export(&c.paste_encode, .{ .name = "ghostty_paste_encode" }); @export(&c.unicode_codepoint_width, .{ .name = "ghostty_unicode_codepoint_width" }); @export(&c.unicode_grapheme_width, .{ .name = "ghostty_unicode_grapheme_width" }); @export(&c.size_report_encode, .{ .name = "ghostty_size_report_encode" }); @@ -237,21 +240,25 @@ comptime { @export(&c.style_is_default, .{ .name = "ghostty_style_is_default" }); @export(&c.sys_log_stderr, .{ .name = "ghostty_sys_log_stderr" }); @export(&c.sys_set, .{ .name = "ghostty_sys_set" }); - @export(&c.cell_get, .{ .name = "ghostty_cell_get" }); - @export(&c.cell_get_multi, .{ .name = "ghostty_cell_get_multi" }); - @export(&c.row_get, .{ .name = "ghostty_row_get" }); - @export(&c.row_get_multi, .{ .name = "ghostty_row_get_multi" }); - @export(&c.color_rgb_get, .{ .name = "ghostty_color_rgb_get" }); - @export(&c.color_contrast, .{ .name = "ghostty_color_contrast" }); - @export(&c.color_luminance, .{ .name = "ghostty_color_luminance" }); - @export(&c.color_parse, .{ .name = "ghostty_color_parse" }); - @export(&c.color_parse_palette_entry, .{ .name = "ghostty_color_parse_palette_entry" }); - @export(&c.color_parse_x11, .{ .name = "ghostty_color_parse_x11" }); - @export(&c.color_palette_default, .{ .name = "ghostty_color_palette_default" }); - @export(&c.color_palette_generate, .{ .name = "ghostty_color_palette_generate" }); - @export(&c.color_perceived_luminance, .{ .name = "ghostty_color_perceived_luminance" }); - @export(&c.color_x11_name_count, .{ .name = "ghostty_color_x11_name_count" }); - @export(&c.color_x11_names, .{ .name = "ghostty_color_x11_names" }); + if (features.grid_introspection) { + @export(&c.cell_get, .{ .name = "ghostty_cell_get" }); + @export(&c.cell_get_multi, .{ .name = "ghostty_cell_get_multi" }); + @export(&c.row_get, .{ .name = "ghostty_row_get" }); + @export(&c.row_get_multi, .{ .name = "ghostty_row_get_multi" }); + } + if (features.color) { + @export(&c.color_rgb_get, .{ .name = "ghostty_color_rgb_get" }); + @export(&c.color_contrast, .{ .name = "ghostty_color_contrast" }); + @export(&c.color_luminance, .{ .name = "ghostty_color_luminance" }); + @export(&c.color_parse, .{ .name = "ghostty_color_parse" }); + @export(&c.color_parse_palette_entry, .{ .name = "ghostty_color_parse_palette_entry" }); + @export(&c.color_parse_x11, .{ .name = "ghostty_color_parse_x11" }); + @export(&c.color_palette_default, .{ .name = "ghostty_color_palette_default" }); + @export(&c.color_palette_generate, .{ .name = "ghostty_color_palette_generate" }); + @export(&c.color_perceived_luminance, .{ .name = "ghostty_color_perceived_luminance" }); + @export(&c.color_x11_name_count, .{ .name = "ghostty_color_x11_name_count" }); + @export(&c.color_x11_names, .{ .name = "ghostty_color_x11_names" }); + } @export(&c.sgr_new, .{ .name = "ghostty_sgr_new" }); @export(&c.sgr_free, .{ .name = "ghostty_sgr_free" }); @export(&c.sgr_reset, .{ .name = "ghostty_sgr_reset" }); @@ -261,33 +268,39 @@ comptime { @export(&c.sgr_unknown_partial, .{ .name = "ghostty_sgr_unknown_partial" }); @export(&c.sgr_attribute_tag, .{ .name = "ghostty_sgr_attribute_tag" }); @export(&c.sgr_attribute_value, .{ .name = "ghostty_sgr_attribute_value" }); - @export(&c.formatter_terminal_new, .{ .name = "ghostty_formatter_terminal_new" }); - @export(&c.formatter_format_buf, .{ .name = "ghostty_formatter_format_buf" }); - @export(&c.formatter_format_alloc, .{ .name = "ghostty_formatter_format_alloc" }); - @export(&c.formatter_free, .{ .name = "ghostty_formatter_free" }); - @export(&c.terminal_selection_format_buf, .{ .name = "ghostty_terminal_selection_format_buf" }); - @export(&c.terminal_selection_format_alloc, .{ .name = "ghostty_terminal_selection_format_alloc" }); - @export(&c.render_state_new, .{ .name = "ghostty_render_state_new" }); - @export(&c.render_state_update, .{ .name = "ghostty_render_state_update" }); - @export(&c.render_state_begin_update, .{ .name = "ghostty_render_state_begin_update" }); - @export(&c.render_state_end_update, .{ .name = "ghostty_render_state_end_update" }); - @export(&c.render_state_get, .{ .name = "ghostty_render_state_get" }); - @export(&c.render_state_get_multi, .{ .name = "ghostty_render_state_get_multi" }); - @export(&c.render_state_set, .{ .name = "ghostty_render_state_set" }); - @export(&c.render_state_colors_get, .{ .name = "ghostty_render_state_colors_get" }); - @export(&c.render_state_row_iterator_new, .{ .name = "ghostty_render_state_row_iterator_new" }); - @export(&c.render_state_row_iterator_next, .{ .name = "ghostty_render_state_row_iterator_next" }); - @export(&c.render_state_row_get, .{ .name = "ghostty_render_state_row_get" }); - @export(&c.render_state_row_get_multi, .{ .name = "ghostty_render_state_row_get_multi" }); - @export(&c.render_state_row_set, .{ .name = "ghostty_render_state_row_set" }); - @export(&c.render_state_row_iterator_free, .{ .name = "ghostty_render_state_row_iterator_free" }); - @export(&c.render_state_row_cells_new, .{ .name = "ghostty_render_state_row_cells_new" }); - @export(&c.render_state_row_cells_next, .{ .name = "ghostty_render_state_row_cells_next" }); - @export(&c.render_state_row_cells_select, .{ .name = "ghostty_render_state_row_cells_select" }); - @export(&c.render_state_row_cells_get, .{ .name = "ghostty_render_state_row_cells_get" }); - @export(&c.render_state_row_cells_get_multi, .{ .name = "ghostty_render_state_row_cells_get_multi" }); - @export(&c.render_state_row_cells_free, .{ .name = "ghostty_render_state_row_cells_free" }); - @export(&c.render_state_free, .{ .name = "ghostty_render_state_free" }); + if (features.formatter) { + @export(&c.formatter_terminal_new, .{ .name = "ghostty_formatter_terminal_new" }); + @export(&c.formatter_format_buf, .{ .name = "ghostty_formatter_format_buf" }); + @export(&c.formatter_format_alloc, .{ .name = "ghostty_formatter_format_alloc" }); + @export(&c.formatter_free, .{ .name = "ghostty_formatter_free" }); + } + if (features.formatter and features.selection) { + @export(&c.terminal_selection_format_buf, .{ .name = "ghostty_terminal_selection_format_buf" }); + @export(&c.terminal_selection_format_alloc, .{ .name = "ghostty_terminal_selection_format_alloc" }); + } + if (features.render_state) { + @export(&c.render_state_new, .{ .name = "ghostty_render_state_new" }); + @export(&c.render_state_update, .{ .name = "ghostty_render_state_update" }); + @export(&c.render_state_begin_update, .{ .name = "ghostty_render_state_begin_update" }); + @export(&c.render_state_end_update, .{ .name = "ghostty_render_state_end_update" }); + @export(&c.render_state_get, .{ .name = "ghostty_render_state_get" }); + @export(&c.render_state_get_multi, .{ .name = "ghostty_render_state_get_multi" }); + @export(&c.render_state_set, .{ .name = "ghostty_render_state_set" }); + @export(&c.render_state_colors_get, .{ .name = "ghostty_render_state_colors_get" }); + @export(&c.render_state_row_iterator_new, .{ .name = "ghostty_render_state_row_iterator_new" }); + @export(&c.render_state_row_iterator_next, .{ .name = "ghostty_render_state_row_iterator_next" }); + @export(&c.render_state_row_get, .{ .name = "ghostty_render_state_row_get" }); + @export(&c.render_state_row_get_multi, .{ .name = "ghostty_render_state_row_get_multi" }); + @export(&c.render_state_row_set, .{ .name = "ghostty_render_state_row_set" }); + @export(&c.render_state_row_iterator_free, .{ .name = "ghostty_render_state_row_iterator_free" }); + @export(&c.render_state_row_cells_new, .{ .name = "ghostty_render_state_row_cells_new" }); + @export(&c.render_state_row_cells_next, .{ .name = "ghostty_render_state_row_cells_next" }); + @export(&c.render_state_row_cells_select, .{ .name = "ghostty_render_state_row_cells_select" }); + @export(&c.render_state_row_cells_get, .{ .name = "ghostty_render_state_row_cells_get" }); + @export(&c.render_state_row_cells_get_multi, .{ .name = "ghostty_render_state_row_cells_get_multi" }); + @export(&c.render_state_row_cells_free, .{ .name = "ghostty_render_state_row_cells_free" }); + @export(&c.render_state_free, .{ .name = "ghostty_render_state_free" }); + } @export(&c.terminal_new, .{ .name = "ghostty_terminal_new" }); @export(&c.terminal_free, .{ .name = "ghostty_terminal_free" }); @export(&c.terminal_reset, .{ .name = "ghostty_terminal_reset" }); @@ -303,66 +316,82 @@ comptime { @export(&c.terminal_continuation_write, .{ .name = "ghostty_terminal_continuation_write" }); @export(&c.terminal_continuation_buf, .{ .name = "ghostty_terminal_continuation_buf" }); @export(&c.terminal_continuation_alloc, .{ .name = "ghostty_terminal_continuation_alloc" }); - @export(&c.terminal_select_word, .{ .name = "ghostty_terminal_select_word" }); - @export(&c.terminal_select_word_between, .{ .name = "ghostty_terminal_select_word_between" }); - @export(&c.terminal_select_line, .{ .name = "ghostty_terminal_select_line" }); - @export(&c.terminal_select_all, .{ .name = "ghostty_terminal_select_all" }); - @export(&c.terminal_select_output, .{ .name = "ghostty_terminal_select_output" }); - @export(&c.terminal_selection_adjust, .{ .name = "ghostty_terminal_selection_adjust" }); - @export(&c.terminal_selection_order, .{ .name = "ghostty_terminal_selection_order" }); - @export(&c.terminal_selection_ordered, .{ .name = "ghostty_terminal_selection_ordered" }); - @export(&c.terminal_selection_contains, .{ .name = "ghostty_terminal_selection_contains" }); - @export(&c.terminal_selection_equal, .{ .name = "ghostty_terminal_selection_equal" }); - @export(&c.selection_gesture_new, .{ .name = "ghostty_selection_gesture_new" }); - @export(&c.selection_gesture_free, .{ .name = "ghostty_selection_gesture_free" }); - @export(&c.selection_gesture_reset, .{ .name = "ghostty_selection_gesture_reset" }); - @export(&c.selection_gesture_event, .{ .name = "ghostty_selection_gesture_event" }); - @export(&c.selection_gesture_get, .{ .name = "ghostty_selection_gesture_get" }); - @export(&c.selection_gesture_get_multi, .{ .name = "ghostty_selection_gesture_get_multi" }); - @export(&c.selection_gesture_event_new, .{ .name = "ghostty_selection_gesture_event_new" }); - @export(&c.selection_gesture_event_free, .{ .name = "ghostty_selection_gesture_event_free" }); - @export(&c.selection_gesture_event_set, .{ .name = "ghostty_selection_gesture_event_set" }); - @export(&c.terminal_grid_ref, .{ .name = "ghostty_terminal_grid_ref" }); - @export(&c.terminal_grid_ref_track, .{ .name = "ghostty_terminal_grid_ref_track" }); - @export(&c.terminal_point_from_grid_ref, .{ .name = "ghostty_terminal_point_from_grid_ref" }); - @export(&c.snapshot_encode, .{ .name = "ghostty_snapshot_encode" }); - @export(&c.snapshot_encode_buf, .{ .name = "ghostty_snapshot_encode_buf" }); - @export(&c.snapshot_encode_alloc, .{ .name = "ghostty_snapshot_encode_alloc" }); - @export(&c.snapshot_decoder_new, .{ .name = "ghostty_snapshot_decoder_new" }); - @export(&c.snapshot_decoder_new_buf, .{ .name = "ghostty_snapshot_decoder_new_buf" }); - @export(&c.snapshot_decoder_free, .{ .name = "ghostty_snapshot_decoder_free" }); - @export(&c.snapshot_decoder_set, .{ .name = "ghostty_snapshot_decoder_set" }); - @export(&c.snapshot_decoder_get, .{ .name = "ghostty_snapshot_decoder_get" }); - @export(&c.snapshot_decoder_get_multi, .{ .name = "ghostty_snapshot_decoder_get_multi" }); - @export(&c.snapshot_decoder_ready, .{ .name = "ghostty_snapshot_decoder_ready" }); - @export(&c.snapshot_decoder_next, .{ .name = "ghostty_snapshot_decoder_next" }); - @export(&c.snapshot_decoder_decode, .{ .name = "ghostty_snapshot_decoder_decode" }); - @export(&c.kitty_graphics_get, .{ .name = "ghostty_kitty_graphics_get" }); - @export(&c.kitty_graphics_image, .{ .name = "ghostty_kitty_graphics_image" }); - @export(&c.kitty_graphics_image_get, .{ .name = "ghostty_kitty_graphics_image_get" }); - @export(&c.kitty_graphics_image_get_multi, .{ .name = "ghostty_kitty_graphics_image_get_multi" }); - @export(&c.kitty_graphics_placement_iterator_new, .{ .name = "ghostty_kitty_graphics_placement_iterator_new" }); - @export(&c.kitty_graphics_placement_iterator_free, .{ .name = "ghostty_kitty_graphics_placement_iterator_free" }); - @export(&c.kitty_graphics_placement_iterator_set, .{ .name = "ghostty_kitty_graphics_placement_iterator_set" }); - @export(&c.kitty_graphics_placement_next, .{ .name = "ghostty_kitty_graphics_placement_next" }); - @export(&c.kitty_graphics_placement_get, .{ .name = "ghostty_kitty_graphics_placement_get" }); - @export(&c.kitty_graphics_placement_get_multi, .{ .name = "ghostty_kitty_graphics_placement_get_multi" }); - @export(&c.kitty_graphics_placement_rect, .{ .name = "ghostty_kitty_graphics_placement_rect" }); - @export(&c.kitty_graphics_placement_pixel_size, .{ .name = "ghostty_kitty_graphics_placement_pixel_size" }); - @export(&c.kitty_graphics_placement_grid_size, .{ .name = "ghostty_kitty_graphics_placement_grid_size" }); - @export(&c.kitty_graphics_placement_viewport_pos, .{ .name = "ghostty_kitty_graphics_placement_viewport_pos" }); - @export(&c.kitty_graphics_placement_source_rect, .{ .name = "ghostty_kitty_graphics_placement_source_rect" }); - @export(&c.kitty_graphics_placement_render_info, .{ .name = "ghostty_kitty_graphics_placement_render_info" }); - @export(&c.grid_ref_cell, .{ .name = "ghostty_grid_ref_cell" }); - @export(&c.grid_ref_row, .{ .name = "ghostty_grid_ref_row" }); - @export(&c.grid_ref_graphemes, .{ .name = "ghostty_grid_ref_graphemes" }); - @export(&c.grid_ref_hyperlink_uri, .{ .name = "ghostty_grid_ref_hyperlink_uri" }); - @export(&c.grid_ref_style, .{ .name = "ghostty_grid_ref_style" }); - @export(&c.tracked_grid_ref_free, .{ .name = "ghostty_tracked_grid_ref_free" }); - @export(&c.tracked_grid_ref_has_value, .{ .name = "ghostty_tracked_grid_ref_has_value" }); - @export(&c.tracked_grid_ref_point, .{ .name = "ghostty_tracked_grid_ref_point" }); - @export(&c.tracked_grid_ref_set, .{ .name = "ghostty_tracked_grid_ref_set" }); - @export(&c.tracked_grid_ref_snapshot, .{ .name = "ghostty_tracked_grid_ref_snapshot" }); + if (features.selection) { + @export(&c.terminal_select_word, .{ .name = "ghostty_terminal_select_word" }); + @export(&c.terminal_select_word_between, .{ .name = "ghostty_terminal_select_word_between" }); + @export(&c.terminal_select_line, .{ .name = "ghostty_terminal_select_line" }); + @export(&c.terminal_select_all, .{ .name = "ghostty_terminal_select_all" }); + @export(&c.terminal_select_output, .{ .name = "ghostty_terminal_select_output" }); + @export(&c.terminal_selection_adjust, .{ .name = "ghostty_terminal_selection_adjust" }); + @export(&c.terminal_selection_order, .{ .name = "ghostty_terminal_selection_order" }); + @export(&c.terminal_selection_ordered, .{ .name = "ghostty_terminal_selection_ordered" }); + @export(&c.terminal_selection_contains, .{ .name = "ghostty_terminal_selection_contains" }); + @export(&c.terminal_selection_equal, .{ .name = "ghostty_terminal_selection_equal" }); + @export(&c.selection_gesture_new, .{ .name = "ghostty_selection_gesture_new" }); + @export(&c.selection_gesture_free, .{ .name = "ghostty_selection_gesture_free" }); + @export(&c.selection_gesture_reset, .{ .name = "ghostty_selection_gesture_reset" }); + @export(&c.selection_gesture_event, .{ .name = "ghostty_selection_gesture_event" }); + @export(&c.selection_gesture_get, .{ .name = "ghostty_selection_gesture_get" }); + @export(&c.selection_gesture_get_multi, .{ .name = "ghostty_selection_gesture_get_multi" }); + @export(&c.selection_gesture_event_new, .{ .name = "ghostty_selection_gesture_event_new" }); + @export(&c.selection_gesture_event_free, .{ .name = "ghostty_selection_gesture_event_free" }); + @export(&c.selection_gesture_event_set, .{ .name = "ghostty_selection_gesture_event_set" }); + } + // Selections are expressed in grid references, so the untracked + // reference constructors are required by both features. + if (features.grid_introspection or features.selection) { + @export(&c.terminal_grid_ref, .{ .name = "ghostty_terminal_grid_ref" }); + @export(&c.terminal_point_from_grid_ref, .{ .name = "ghostty_terminal_point_from_grid_ref" }); + } + if (features.grid_introspection) { + @export(&c.terminal_grid_ref_track, .{ .name = "ghostty_terminal_grid_ref_track" }); + } + if (features.snapshot) { + @export(&c.snapshot_encode, .{ .name = "ghostty_snapshot_encode" }); + @export(&c.snapshot_encode_buf, .{ .name = "ghostty_snapshot_encode_buf" }); + @export(&c.snapshot_encode_alloc, .{ .name = "ghostty_snapshot_encode_alloc" }); + @export(&c.snapshot_decoder_new, .{ .name = "ghostty_snapshot_decoder_new" }); + @export(&c.snapshot_decoder_new_buf, .{ .name = "ghostty_snapshot_decoder_new_buf" }); + @export(&c.snapshot_decoder_free, .{ .name = "ghostty_snapshot_decoder_free" }); + @export(&c.snapshot_decoder_set, .{ .name = "ghostty_snapshot_decoder_set" }); + @export(&c.snapshot_decoder_get, .{ .name = "ghostty_snapshot_decoder_get" }); + @export(&c.snapshot_decoder_get_multi, .{ .name = "ghostty_snapshot_decoder_get_multi" }); + @export(&c.snapshot_decoder_ready, .{ .name = "ghostty_snapshot_decoder_ready" }); + @export(&c.snapshot_decoder_next, .{ .name = "ghostty_snapshot_decoder_next" }); + @export(&c.snapshot_decoder_decode, .{ .name = "ghostty_snapshot_decoder_decode" }); + } + if (features.kitty_graphics) { + @export(&c.kitty_graphics_get, .{ .name = "ghostty_kitty_graphics_get" }); + @export(&c.kitty_graphics_image, .{ .name = "ghostty_kitty_graphics_image" }); + @export(&c.kitty_graphics_image_get, .{ .name = "ghostty_kitty_graphics_image_get" }); + @export(&c.kitty_graphics_image_get_multi, .{ .name = "ghostty_kitty_graphics_image_get_multi" }); + @export(&c.kitty_graphics_placement_iterator_new, .{ .name = "ghostty_kitty_graphics_placement_iterator_new" }); + @export(&c.kitty_graphics_placement_iterator_free, .{ .name = "ghostty_kitty_graphics_placement_iterator_free" }); + @export(&c.kitty_graphics_placement_iterator_set, .{ .name = "ghostty_kitty_graphics_placement_iterator_set" }); + @export(&c.kitty_graphics_placement_next, .{ .name = "ghostty_kitty_graphics_placement_next" }); + @export(&c.kitty_graphics_placement_get, .{ .name = "ghostty_kitty_graphics_placement_get" }); + @export(&c.kitty_graphics_placement_get_multi, .{ .name = "ghostty_kitty_graphics_placement_get_multi" }); + @export(&c.kitty_graphics_placement_rect, .{ .name = "ghostty_kitty_graphics_placement_rect" }); + @export(&c.kitty_graphics_placement_pixel_size, .{ .name = "ghostty_kitty_graphics_placement_pixel_size" }); + @export(&c.kitty_graphics_placement_grid_size, .{ .name = "ghostty_kitty_graphics_placement_grid_size" }); + @export(&c.kitty_graphics_placement_viewport_pos, .{ .name = "ghostty_kitty_graphics_placement_viewport_pos" }); + @export(&c.kitty_graphics_placement_source_rect, .{ .name = "ghostty_kitty_graphics_placement_source_rect" }); + @export(&c.kitty_graphics_placement_render_info, .{ .name = "ghostty_kitty_graphics_placement_render_info" }); + } + if (features.grid_introspection) { + @export(&c.grid_ref_cell, .{ .name = "ghostty_grid_ref_cell" }); + @export(&c.grid_ref_row, .{ .name = "ghostty_grid_ref_row" }); + @export(&c.grid_ref_graphemes, .{ .name = "ghostty_grid_ref_graphemes" }); + @export(&c.grid_ref_hyperlink_uri, .{ .name = "ghostty_grid_ref_hyperlink_uri" }); + @export(&c.grid_ref_style, .{ .name = "ghostty_grid_ref_style" }); + @export(&c.tracked_grid_ref_free, .{ .name = "ghostty_tracked_grid_ref_free" }); + @export(&c.tracked_grid_ref_has_value, .{ .name = "ghostty_tracked_grid_ref_has_value" }); + @export(&c.tracked_grid_ref_point, .{ .name = "ghostty_tracked_grid_ref_point" }); + @export(&c.tracked_grid_ref_set, .{ .name = "ghostty_tracked_grid_ref_set" }); + } + if (features.grid_introspection and features.snapshot) { + @export(&c.tracked_grid_ref_snapshot, .{ .name = "ghostty_tracked_grid_ref_snapshot" }); + } @export(&c.build_info, .{ .name = "ghostty_build_info" }); @export(&c.type_json, .{ .name = "ghostty_type_json" }); @export(&c.alloc_alloc, .{ .name = "ghostty_alloc" }); diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index e1729b461..1e974cdd5 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -15873,6 +15873,8 @@ test "Terminal: deleteLines wide char at right margin with full clear" { } test "Terminal: glyph APC stores session glossary entries" { + if (comptime !build_options.glyph_protocol) return error.SkipZigTest; + const alloc = testing.allocator; const io_impl = testing.io; var t = try init(io_impl, alloc, .{ .cols = 80, .rows = 24 }); diff --git a/src/terminal/apc.zig b/src/terminal/apc.zig index 0da13b3b3..1ae70f5f1 100644 --- a/src/terminal/apc.zig +++ b/src/terminal/apc.zig @@ -82,12 +82,16 @@ pub const Handler = struct { if (byte == ';') { const str = id.buf[0..id.len]; if (std.mem.eql(u8, str, glyph.identifier)) { - if (self.enabled.contains(.glyph)) { - self.state = .{ .glyph = .init( - alloc, - self.max_bytes.get(.glyph) orelse - Protocol.defaultMaxBytes(.glyph), - ) }; + if (comptime build_options.glyph_protocol) { + if (self.enabled.contains(.glyph)) { + self.state = .{ .glyph = .init( + alloc, + self.max_bytes.get(.glyph) orelse + Protocol.defaultMaxBytes(.glyph), + ) }; + } else { + self.state = .ignore; + } } else { self.state = .ignore; } @@ -127,11 +131,13 @@ pub const Handler = struct { }; } else unreachable, - .glyph => |*p| p.feed(byte) catch |err| { - log.warn("glyph protocol error: {}", .{err}); - p.deinit(); - self.state = .ignore; - }, + .glyph => |*p| if (comptime build_options.glyph_protocol) { + p.feed(byte) catch |err| { + log.warn("glyph protocol error: {}", .{err}); + p.deinit(); + self.state = .ignore; + }; + } else unreachable, } } @@ -191,14 +197,14 @@ pub const Handler = struct { return; } else unreachable, - .glyph => |*p| { + .glyph => |*p| if (comptime build_options.glyph_protocol) { p.feedSlice(rem) catch |err| { log.warn("glyph protocol error: {}", .{err}); p.deinit(); self.state = .ignore; }; return; - }, + } else unreachable, } } } @@ -229,6 +235,8 @@ pub const Handler = struct { }, .glyph => |*p| glyph_cmd: { + if (comptime !build_options.glyph_protocol) unreachable; + const command = p.complete(p.alloc) catch |err| { log.warn("glyph protocol error: {}", .{err}); break :glyph_cmd null; @@ -269,7 +277,10 @@ pub const State = union(enum) { void, /// Glyph protocol - glyph: glyph.CommandParser, + glyph: if (build_options.glyph_protocol) + glyph.CommandParser + else + void, /// An unsupported APC retained for the optional unknown callback. /// Keep this after recognized protocol states so their tag values and @@ -280,7 +291,10 @@ pub const State = union(enum) { switch (self.*) { .inactive, .ignore, .identify => {}, .unknown => |*v| v.deinit(), - .glyph => |*v| v.deinit(), + .glyph => |*v| if (comptime build_options.glyph_protocol) + v.deinit() + else + unreachable, .kitty => |*v| if (comptime build_options.kitty_graphics) v.deinit() else @@ -415,7 +429,11 @@ pub const Command = union(enum) { else void, - glyph: glyph.Request, + glyph: if (build_options.glyph_protocol) + glyph.Request + else + void, + unknown: Unknown, pub fn deinit(self: *Command, alloc: Allocator) void { @@ -425,7 +443,11 @@ pub const Command = union(enum) { else unreachable, - .glyph => |*v| v.deinit(alloc), + .glyph => |*v| if (comptime build_options.glyph_protocol) + v.deinit(alloc) + else + unreachable, + .unknown => |*v| v.deinit(alloc), } } @@ -636,6 +658,8 @@ test "garbage glyph command" { } test "valid glyph command" { + if (comptime !build_options.glyph_protocol) return error.SkipZigTest; + const testing = std.testing; const alloc = testing.allocator; @@ -700,6 +724,8 @@ test "feedSlice unknown APC command is ignored" { } test "feedSlice valid glyph command" { + if (comptime !build_options.glyph_protocol) return error.SkipZigTest; + const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/build_options.zig b/src/terminal/build_options.zig index 136e0f101..124929d28 100644 --- a/src/terminal/build_options.zig +++ b/src/terminal/build_options.zig @@ -36,9 +36,227 @@ pub const Options = struct { /// Options. c_abi: bool, + /// Optional feature gates, all enabled by default. See Features. + features: Features = .{}, + /// The version of the application. version: std.SemanticVersion, + /// Optional features for lib artifacts. Disabling a feature removes + /// its C API exports and any stream-integrated handling from the + /// build, primarily so size-conscious embedders (e.g. wasm) can trim + /// the binary. The Ghostty application itself always builds with + /// every feature enabled. + /// + /// Field names are the accepted tokens for `-Dvt-features`, which + /// acts like `-Dcpu`: comma-separated modifications applied on top + /// of the default (all enabled) set. `+feature` and `feature` + /// enable, `-feature` disables. The special name `all` refers to + /// every feature, so `-Dvt-features=-all,+render-state` builds only + /// the render state API. Hyphens in names are interchangeable with + /// underscores. Fields are emitted verbatim as bool options on the + /// `terminal_options` module. + pub const Features = packed struct { + /// Terminal state serialization: encode a full terminal (screen + /// contents, scrollback, cursor, styles, modes, etc.) to a + /// compact binary format and decode it back into a live + /// terminal. Used for session persistence, window restoration, + /// and moving terminal state between processes. + /// + /// C API: `ghostty_snapshot_*` and, together with + /// `grid_introspection`, `ghostty_tracked_grid_ref_snapshot`. + snapshot: bool = true, + + /// Textual export of terminal contents as plain text, VT + /// (replayable escape sequences), or HTML, with optional extras + /// such as cursor position, palette, modes, and tabstops. Used + /// for copy-to-clipboard, dump-to-file, and debugging. + /// + /// C API: `ghostty_formatter_*` and, together with `selection`, + /// `ghostty_terminal_selection_format_*`. + formatter: bool = true, + + /// Text selection: select word/line/output/all, selection + /// ordering, adjustment, and containment math, and the + /// click/drag gesture state machine (single/double/triple + /// click behaviors). Selections are expressed in grid + /// references, so enabling this also keeps the untracked grid + /// reference constructors even if `grid_introspection` is + /// disabled. + /// + /// C API: `ghostty_terminal_select_*`, + /// `ghostty_terminal_selection_*`, `ghostty_selection_gesture_*`. + selection: bool = true, + + /// The render state API: a coherent, update-in-place view of + /// the visible screen (rows, cells, styles, cursor, colors, + /// palette) designed to drive a renderer at frame rates. This + /// is the primary read path for embedders that draw the + /// terminal. + /// + /// C API: `ghostty_render_state_*`. + render_state: bool = true, + + /// Encoding of host input events into the byte sequences a + /// terminal application expects: keyboard input (including the + /// Kitty keyboard protocol), mouse reporting, focus reporting, + /// and (bracketed) paste. Any interactive embedder needs this; + /// a read-only viewer does not. + /// + /// C API: `ghostty_key_event_*`, `ghostty_key_encoder_*`, + /// `ghostty_mouse_event_*`, `ghostty_mouse_encoder_*`, + /// `ghostty_focus_encode`, `ghostty_paste_*`. + input_encode: bool = true, + + /// Color utilities: luminance, perceived luminance, and + /// contrast math (e.g. for minimum-contrast rendering), color + /// string parsing (`#rrggbb`, X11 color names, XParseColor + /// `rgb:`/`rgbi:` device specs), the default 256-color palette + /// and palette generation from configurable bases, and the X11 + /// color name table itself. + /// + /// C API: `ghostty_color_*`. + color: bool = true, + + /// Direct inspection of grid contents at a position via grid + /// references: cell and row data getters (codepoints, + /// graphemes, style, hyperlink URI, wrap state, ...) plus + /// tracked grid references that stay valid across terminal + /// updates. Used for hyperlink hover/open, accessibility, and + /// tests; deliberately not built for render loops (use + /// `render_state` for that). + /// + /// C API: `ghostty_grid_ref_*`, `ghostty_tracked_grid_ref_*`, + /// `ghostty_cell_get*`, `ghostty_row_get*`, and (shared with + /// `selection`) `ghostty_terminal_grid_ref` / + /// `ghostty_terminal_point_from_grid_ref`. + grid_introspection: bool = true, + + /// The APC glyph protocol: a Ghostty extension that lets + /// terminal applications register custom font glyphs (glyf + /// outlines) for private-use-area codepoints. Embedders that + /// don't render registered glyphs can disable this; the + /// sequences are still consumed and safely ignored. + /// + /// No C API surface of its own; this gates the stream handling + /// and glossary storage. + glyph_protocol: bool = true, + + /// The Kitty graphics protocol: APC command parsing, image + /// transmission and storage (PNG decoding via the sys + /// interface), and placement tracking, plus the read APIs a + /// renderer uses to draw placed images. Disabled sequences are + /// still consumed and safely ignored. + /// + /// This requires the ability to get timestamps from the OS, so + /// it is always disabled on freestanding targets (e.g. + /// wasm32-freestanding) regardless of this setting. + /// + /// C API: `ghostty_kitty_graphics_*`. + kitty_graphics: bool = true, + + pub fn parse(list: []const u8) error{UnknownFeature}!Features { + // Modifications apply on top of the default set. + var result: Features = .{}; + + var it = std.mem.splitAny(u8, list, ", "); + while (it.next()) |raw| { + if (raw.len == 0) continue; + + var name = raw; + var enable = true; + switch (name[0]) { + '+' => name = name[1..], + '-' => { + enable = false; + name = name[1..]; + }, + else => {}, + } + + // A dangling sign with no name is malformed. + if (name.len == 0) return error.UnknownFeature; + + // The special name "all" refers to every feature. + if (std.mem.eql(u8, name, "all")) { + inline for (@typeInfo(Features).@"struct".fields) |field| { + @field(result, field.name) = enable; + } + continue; + } + + var found = false; + inline for (@typeInfo(Features).@"struct".fields) |field| { + if (eqlName(name, field.name)) { + @field(result, field.name) = enable; + found = true; + } + } + if (!found) return error.UnknownFeature; + } + + return result; + } + + /// Feature name equality where '-' in the input matches '_' in + /// the field name. + fn eqlName(input: []const u8, name: []const u8) bool { + if (input.len != name.len) return false; + for (input, name) |a, b| { + const norm: u8 = if (a == '-') '_' else a; + if (norm != b) return false; + } + return true; + } + + test "parse" { + const testing = std.testing; + + const none: Features = @bitCast( + @as(@typeInfo(Features).@"struct".backing_integer.?, 0), + ); + + // Empty input keeps the defaults. + try testing.expectEqual(Features{}, try Features.parse("")); + + // All three token forms, comma separated. + try testing.expectEqual( + Features{ .snapshot = false }, + try Features.parse("-snapshot"), + ); + try testing.expectEqual( + Features{ .snapshot = false, .formatter = false }, + try Features.parse("-snapshot,+render_state,-formatter,color"), + ); + + // The special "all" name refers to every feature. + try testing.expectEqual(none, try Features.parse("-all")); + try testing.expectEqual(Features{}, try Features.parse("-all,+all")); + var render_only = none; + render_only.render_state = true; + try testing.expectEqual( + render_only, + try Features.parse("-all,+render-state"), + ); + + // Hyphens are interchangeable with underscores. + try testing.expectEqual( + Features{ .input_encode = false, .grid_introspection = false }, + try Features.parse("-input-encode,-grid_introspection"), + ); + + // Later tokens win. + try testing.expectEqual( + Features{}, + try Features.parse("-snapshot,+snapshot"), + ); + + // Unknown names and dangling signs are errors. + try testing.expectError(error.UnknownFeature, Features.parse("bogus")); + try testing.expectError(error.UnknownFeature, Features.parse("-snapshot,-")); + } + }; + /// Add the required build options for the terminal module. /// /// The memory referenced by self is expected to stick around (it isn't @@ -55,25 +273,25 @@ pub const Options = struct { opts.addOption(bool, "simd", self.simd); opts.addOption(bool, "slow_runtime_safety", self.slow_runtime_safety); - // Kitty graphics is almost always true. This used to be conditional on - // some other factors but we've since generalized the implementation - // to support optional PNG decoding, OS capabilities like filesystems, - // etc. So its safe to always enable it and just have the - // implementation deal with unsupported features as needed. - // - // We disable it on wasm32-freestanding because we at the least - // require the ability to get timestamps and there is no way to - // do that with freestanding targets. - const target = m.resolved_target.?.result; - opts.addOption( - bool, - "kitty_graphics", - !(target.cpu.arch == .wasm32 and target.os.tag == .freestanding), - ); - // These are synthesized based on other options. opts.addOption(bool, "tmux_control_mode", self.oniguruma); + // Feature gates, emitted as flat bools (e.g. `options.snapshot`). + const target = m.resolved_target.?.result; + inline for (@typeInfo(Features).@"struct".fields) |field| { + var value = @field(self.features, field.name); + + // Kitty graphics requires the ability to get timestamps and + // there is no way to do that on freestanding targets, so it + // is always disabled there regardless of the feature setting. + if (comptime std.mem.eql(u8, field.name, "kitty_graphics")) { + if (target.cpu.arch == .wasm32 and target.os.tag == .freestanding) + value = false; + } + + opts.addOption(bool, field.name, value); + } + // Version information. opts.addOption( []const u8, @@ -92,3 +310,7 @@ pub const Options = struct { m.addOptions("terminal_options", opts); } }; + +test { + _ = Options.Features; +} diff --git a/src/terminal/c/kitty_graphics.zig b/src/terminal/c/kitty_graphics.zig index 396b463fb..e27dd5db2 100644 --- a/src/terminal/c/kitty_graphics.zig +++ b/src/terminal/c/kitty_graphics.zig @@ -639,6 +639,8 @@ fn computeViewportPos( } test "placement_iterator new/free" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + var iter: PlacementIterator = null; try testing.expectEqual(Result.success, placement_iterator_new( &lib.alloc.test_allocator, diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index 24c5edb39..7dcca5f02 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -5405,6 +5405,8 @@ test "set color sets dirty flag" { } test "set glyph protocol disables APC handling and clears glossary" { + if (comptime !build_options.glyph_protocol) return error.SkipZigTest; + var t: Terminal = null; try testing.expectEqual(Result.success, new( &lib.alloc.test_allocator, diff --git a/src/terminal/stream_terminal.zig b/src/terminal/stream_terminal.zig index 2599097af..91ad59be4 100644 --- a/src/terminal/stream_terminal.zig +++ b/src/terminal/stream_terminal.zig @@ -1054,7 +1054,7 @@ pub const Handler = struct { } }, - .glyph => |*glyph_req| { + .glyph => |*glyph_req| if (comptime build_options.glyph_protocol) { const resp = self.terminal.glyphProtocol(alloc, glyph_req); if (resp) |r| resp_block: { // Don't waste time encoding if we can't write responses @@ -1824,6 +1824,8 @@ test "full reset" { } test "glyph protocol APC with write_pty callback" { + if (comptime !build_options.glyph_protocol) return error.SkipZigTest; + var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); defer t.deinit(testing.allocator);