diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..af3c30be7 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,79 @@ +# Ghostty Development Process + +This document describes the development process for Ghostty. It is intended for +anyone considering opening an **issue** or **pull request**. If in doubt, +please open a [discussion](https://github.com/ghostty-org/ghostty/discussions); +we can always convert that to an issue later. + +> [!NOTE] +> +> I'm sorry for the wall of text. I'm not trying to be difficult and I do +> appreciate your contributions. Ghostty is a personal project for me that +> I maintain in my free time. If you're expecting me to dedicate my personal +> time to fixing bugs, maintaining features, and reviewing code, I do kindly +> ask you spend a few minutes reading this document. Thank you. ❤️ + +## Quick Guide + +**I'd like to contribute!** + +All issues are actionable. Pick one and start working on it. Thank you. +If you need help or guidance, comment on the issue. Issues that are extra +friendly to new contributors are tagged with "contributor friendly". + +**I have a bug!** + +1. Search the issue tracker and discussions for similar issues. +2. If you don't have steps to reproduce, open a discussion. +3. If you have steps to reproduce, open an issue. + +**I have an idea for a feature!** + +1. Open a discussion. + +**I've implemented a feature!** + +1. If there is an issue for the feature, open a pull request. +2. If there is no issue, open a discussion and link to your branch. +3. If you want to live dangerously, open a pull request and hope for the best. + +**I have a question!** + +1. Open a discussion or use Discord. + +## General Patterns + +### Issues are Actionable + +The Ghostty [issue tracker](https://github.com/ghostty-org/ghostty/issues) +is for _actionable items_. + +Unlike some other projects, Ghostty **does not use the issue tracker for +discussion or feature requests**. Instead, we use GitHub +[discussions](https://github.com/ghostty-org/ghostty/discussions) for that. +Once a discussion reaches a point where a well-understood, actionable +item is identified, it is moved to the issue tracker. **This pattern +makes it easier for maintainers or contributors to find issues to work on +since _every issue_ is ready to be worked on.** + +If you are experiencing a bug and have clear steps to reproduce it, please +open an issue. If you are experiencing a bug but you are not sure how to +reproduce it or aren't sure if it's a bug, please open a discussion. +If you have an idea for a feature, please open a discussion. + +### Pull Requests Implement an Issue + +Pull requests should be associated with a previously accepted issue. +**If you open a pull request for something that wasn't previously discussed,** +it may be closed or remain stale for an indefinite period of time. I'm not +saying it will never be accepted, but the odds are stacked against you. + +Issues tagged with "feature" represent accepted, well-scoped feature requests. +If you implement an issue tagged with feature as described in the issue, your +pull request will be accepted with a high degree of certainty. + +> [!NOTE] +> +> **Pull requests are NOT a place to discuss feature design.** Please do +> not open a WIP pull request to discuss a feature. Instead, use a discussion +> and link to your branch. diff --git a/PACKAGING.md b/PACKAGING.md index 5e6560c34..f31252272 100644 --- a/PACKAGING.md +++ b/PACKAGING.md @@ -91,3 +91,8 @@ relevant to package maintainers: - `-Dcpu=baseline`: Build for the "baseline" CPU of the target architecture. This avoids building for newer CPU features that may not be available on all target machines. + +- `-Dtarget=$arch-$os-$abi`: Build for a specific target triple. This is + often necessary for system packages to specify a specific minimum Linux + version, glibc, etc. Run `zig targets` to a get a full list of available + targets. diff --git a/README.md b/README.md index 3b1077795..3c3a2460d 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ placed at `$XDG_CONFIG_HOME/ghostty/config`, which defaults to The file format is documented below as an example: -``` +```ini # The syntax is "key = value". The whitespace around the equals doesn't matter. background = 282c34 foreground= ffffff @@ -375,9 +375,9 @@ test cases. We believe Ghostty is one of the most compliant terminal emulators available. -Terminal behavior is partially a dejour standard +Terminal behavior is partially a de jure standard (i.e. [ECMA-48](https://ecma-international.org/publications-and-standards/standards/ecma-48/)) -but mostly a defacto standard as defined by popular terminal emulators +but mostly a de facto standard as defined by popular terminal emulators worldwide. Ghostty takes the approach that our behavior is defined by (1) standards, if available, (2) xterm, if the feature exists, (3) other popular terminals, in that order. This defines what the Ghostty project @@ -789,7 +789,14 @@ Below is an example: # # Instead, either run `nix flake update` or `nixos-rebuild build` # as the current user, and then run `sudo nixos-rebuild switch`. - ghostty.url = "git+ssh://git@github.com/ghostty-org/ghostty"; + ghostty = { + url = "git+ssh://git@github.com/ghostty-org/ghostty"; + + # NOTE: The below 2 lines are only required on nixos-unstable, + # if you're on stable, they may break your build + inputs.nixpkgs-stable.follows = "nixpkgs"; + inputs.nixpkgs-unstable.follows = "nixpkgs"; + }; }; outputs = { nixpkgs, ghostty, ... }: { diff --git a/build.zig b/build.zig index d4b0df667..2cce41a31 100644 --- a/build.zig +++ b/build.zig @@ -10,6 +10,7 @@ const font = @import("src/font/main.zig"); const renderer = @import("src/renderer.zig"); const terminfo = @import("src/terminfo/main.zig"); const config_vim = @import("src/config/vim.zig"); +const config_sublime_syntax = @import("src/config/sublime_syntax.zig"); const fish_completions = @import("src/build/fish_completions.zig"); const build_config = @import("src/build_config.zig"); const BuildConfig = build_config.BuildConfig; @@ -499,6 +500,38 @@ pub fn build(b: *std.Build) !void { }); } + // Neovim plugin + // This is just a copy-paste of the Vim plugin, but using a Neovim subdir. + // By default, Neovim doesn't look inside share/vim/vimfiles. Some distros + // configure it to do that however. Fedora, does not as a counterexample. + { + const wf = b.addWriteFiles(); + _ = wf.add("syntax/ghostty.vim", config_vim.syntax); + _ = wf.add("ftdetect/ghostty.vim", config_vim.ftdetect); + _ = wf.add("ftplugin/ghostty.vim", config_vim.ftplugin); + b.installDirectory(.{ + .source_dir = wf.getDirectory(), + .install_dir = .prefix, + .install_subdir = "share/nvim/site", + }); + } + + // Sublime syntax highlighting for bat cli tool + // NOTE: The current implementation requires symlinking the generated + // 'ghostty.sublime-syntax' file from zig-out to the '~.config/bat/syntaxes' + // directory. The syntax then needs to be mapped to the correct language in + // the config file within the '~.config/bat' directory + // (ex: --map-syntax "/Users/user/.config/ghostty/config:Ghostty Config"). + { + const wf = b.addWriteFiles(); + _ = wf.add("ghostty.sublime-syntax", config_sublime_syntax.syntax); + b.installDirectory(.{ + .source_dir = wf.getDirectory(), + .install_dir = .prefix, + .install_subdir = "share/bat/syntaxes", + }); + } + // Documentation if (emit_docs) { try buildDocumentation(b, config); @@ -975,7 +1008,7 @@ fn addDeps( if (b.systemIntegrationOption("freetype", .{})) { step.linkSystemLibrary2("bzip2", dynamic_link_opts); - step.linkSystemLibrary2("freetype", dynamic_link_opts); + step.linkSystemLibrary2("freetype2", dynamic_link_opts); } else { step.linkLibrary(freetype_dep.artifact("freetype")); try static_libs.append(freetype_dep.artifact("freetype").getEmittedBin()); @@ -1068,6 +1101,7 @@ fn addDeps( step.root_module.addImport("glslang", glslang_dep.module("glslang")); if (b.systemIntegrationOption("glslang", .{})) { step.linkSystemLibrary2("glslang", dynamic_link_opts); + step.linkSystemLibrary2("glslang-default-resource-limits", dynamic_link_opts); } else { step.linkLibrary(glslang_dep.artifact("glslang")); try static_libs.append(glslang_dep.artifact("glslang").getEmittedBin()); @@ -1226,14 +1260,7 @@ fn addDeps( .optimize = optimize, }); - // This is a bit of a hack that should probably be fixed upstream - // in zig-objc, but we need to add the apple SDK paths to the - // zig-objc module so that it can find the objc runtime headers. - const module = objc_dep.module("objc"); - module.resolved_target = step.root_module.resolved_target; - try @import("apple_sdk").addPaths(b, module); - step.root_module.addImport("objc", module); - + step.root_module.addImport("objc", objc_dep.module("objc")); step.root_module.addImport("macos", macos_dep.module("macos")); step.linkLibrary(macos_dep.artifact("macos")); try static_libs.append(macos_dep.artifact("macos").getEmittedBin()); diff --git a/build.zig.zon b/build.zig.zon index b0c409778..ad9a92d11 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -14,8 +14,8 @@ .lazy = true, }, .zig_objc = .{ - .url = "https://github.com/mitchellh/zig-objc/archive/fe5ac419530cf800294369d996133fe9cd067aec.tar.gz", - .hash = "122034b3e15d582d8d101a7713e5f13c872b8b8eb6d9cb47515b8e34ee75e122630d", + .url = "https://github.com/mitchellh/zig-objc/archive/9b8ba849b0f58fe207ecd6ab7c147af55b17556e.tar.gz", + .hash = "1220e17e64ef0ef561b3e4b9f3a96a2494285f2ec31c097721bf8c8677ec4415c634", }, .zig_js = .{ .url = "https://github.com/mitchellh/zig-js/archive/d0b8b0a57c52fbc89f9d9fecba75ca29da7dd7d1.tar.gz", @@ -49,8 +49,8 @@ // Other .apple_sdk = .{ .path = "./pkg/apple-sdk" }, .iterm2_themes = .{ - .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/b4a9c4d.tar.gz", - .hash = "122056fbb29863ec1678b7954fb76b1533ad8c581a34577c1b2efe419e29e05596df", + .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/da56d590c4237c96d81cc5ed987ea098eebefdf6.tar.gz", + .hash = "1220fac17a112b0dd11ec85e5b31a30f05bfaed897c03f31285276544db30d010c41", }, .vaxis = .{ .url = "git+https://github.com/rockorager/libvaxis?ref=main#a1b43d24653670d612b91f0855b165e6c987b809", diff --git a/include/ghostty.h b/include/ghostty.h index ca70456d8..f4836f210 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -512,6 +512,21 @@ typedef struct { ghostty_input_trigger_s trigger; } ghostty_action_key_sequence_s; +// apprt.action.ColorKind +typedef enum { + GHOSTTY_ACTION_COLOR_KIND_FOREGROUND = -1, + GHOSTTY_ACTION_COLOR_KIND_BACKGROUND = -2, + GHOSTTY_ACTION_COLOR_KIND_CURSOR = -3, +} ghostty_action_color_kind_e; + +// apprt.action.ColorChange +typedef struct { + ghostty_action_color_kind_e kind; + uint8_t r; + uint8_t g; + uint8_t b; +} ghostty_action_color_change_s; + // apprt.Action.Key typedef enum { GHOSTTY_ACTION_NEW_WINDOW, @@ -545,6 +560,7 @@ typedef enum { GHOSTTY_ACTION_QUIT_TIMER, GHOSTTY_ACTION_SECURE_INPUT, GHOSTTY_ACTION_KEY_SEQUENCE, + GHOSTTY_ACTION_COLOR_CHANGE, } ghostty_action_tag_e; typedef union { @@ -567,6 +583,7 @@ typedef union { ghostty_action_quit_timer_e quit_timer; ghostty_action_secure_input_e secure_input; ghostty_action_key_sequence_s key_sequence; + ghostty_action_color_change_s color_change; } ghostty_action_u; typedef struct { diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index c382a62a0..bdd427be0 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -310,6 +310,19 @@ class QuickTerminalController: BaseTerminalController { return } + // Terminals typically operate in sRGB color space and macOS defaults + // to "native" which is typically P3. There is a lot more resources + // covered in this GitHub issue: https://github.com/mitchellh/ghostty/pull/376 + // Ghostty defaults to sRGB but this can be overridden. + switch (ghostty.config.windowColorspace) { + case "display-p3": + window.colorSpace = .displayP3 + case "srgb": + fallthrough + default: + window.colorSpace = .sRGB + } + // If we have window transparency then set it transparent. Otherwise set it opaque. if (ghostty.config.backgroundOpacity < 1) { window.isOpaque = false diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 432345627..000d72418 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -146,14 +146,18 @@ class BaseTerminalController: NSWindowController, } // MARK: Notifications - + @objc private func didChangeScreenParametersNotification(_ notification: Notification) { // If we have a window that is visible and it is outside the bounds of the // screen then we clamp it back to within the screen. guard let window else { return } guard window.isVisible else { return } - guard let screen = window.screen else { return } + // We ignore fullscreen windows because macOS automatically resizes + // those back to the fullscreen bounds. + guard !window.styleMask.contains(.fullScreen) else { return } + + guard let screen = window.screen else { return } let visibleFrame = screen.visibleFrame var newFrame = window.frame diff --git a/macos/Sources/Features/Terminal/TerminalManager.swift b/macos/Sources/Features/Terminal/TerminalManager.swift index f71e198ee..0766f33b0 100644 --- a/macos/Sources/Features/Terminal/TerminalManager.swift +++ b/macos/Sources/Features/Terminal/TerminalManager.swift @@ -227,7 +227,19 @@ class TerminalManager { // are closing a tabbed window, we want to set the cascade point to be // the next cascade point from this window. if focusedWindow != controller.window { + // The cascadeTopLeft call below should NOT move the window. Starting with + // macOS 15, we found that specifically when used with the new window snapping + // features of macOS 15, this WOULD move the frame. So we keep track of the + // old frame and restore it if necessary. Issue: + // https://github.com/ghostty-org/ghostty/issues/2565 + let oldFrame = focusedWindow.frame + Self.lastCascadePoint = focusedWindow.cascadeTopLeft(from: NSZeroPoint) + + if focusedWindow.frame != oldFrame { + focusedWindow.setFrame(oldFrame, display: true) + } + return } diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index e5320a24a..07acf0f91 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -521,6 +521,8 @@ extension Ghostty { case GHOSTTY_ACTION_KEY_SEQUENCE: keySequence(app, target: target, v: action.action.key_sequence) + case GHOSTTY_ACTION_COLOR_CHANGE: + fallthrough case GHOSTTY_ACTION_CLOSE_ALL_WINDOWS: fallthrough case GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW: diff --git a/nix/zigCacheHash.nix b/nix/zigCacheHash.nix index fc57ed034..ebc46799f 100644 --- a/nix/zigCacheHash.nix +++ b/nix/zigCacheHash.nix @@ -1,3 +1,3 @@ # This file is auto-generated! check build-support/check-zig-cache-hash.sh for # more details. -"sha256-5LBZAExb4PJefW+M0Eo+TcoszhBdIFTGBOv6lte5L0Q=" +"sha256-fTNqNTfElvZPxJiNQJ/RxrSMCiKZPU3705CY7fznKhY=" diff --git a/pkg/cimgui/build.zig b/pkg/cimgui/build.zig index 09d340adb..4b5d56963 100644 --- a/pkg/cimgui/build.zig +++ b/pkg/cimgui/build.zig @@ -32,7 +32,7 @@ pub fn build(b: *std.Build) !void { }; if (b.systemIntegrationOption("freetype", .{})) { - lib.linkSystemLibrary2("freetype", dynamic_link_opts); + lib.linkSystemLibrary2("freetype2", dynamic_link_opts); } else { const freetype = b.dependency("freetype", .{ .target = target, diff --git a/pkg/harfbuzz/build.zig b/pkg/harfbuzz/build.zig index bded32172..b5c5c3c1e 100644 --- a/pkg/harfbuzz/build.zig +++ b/pkg/harfbuzz/build.zig @@ -76,7 +76,8 @@ pub fn build(b: *std.Build) !void { }); if (b.systemIntegrationOption("freetype", .{})) { - lib.linkSystemLibrary2("freetype", dynamic_link_opts); + lib.linkSystemLibrary2("freetype2", dynamic_link_opts); + module.linkSystemLibrary("freetype2", dynamic_link_opts); } else { lib.linkLibrary(freetype.artifact("freetype")); module.addIncludePath(freetype.builder.dependency("freetype", .{}).path("include")); diff --git a/src/App.zig b/src/App.zig index c54c67167..cc8277c52 100644 --- a/src/App.zig +++ b/src/App.zig @@ -66,6 +66,11 @@ font_grid_set: font.SharedGridSet, last_notification_time: ?std.time.Instant = null, last_notification_digest: u64 = 0, +/// Set to false once we've created at least one surface. This +/// never goes true again. This can be used by surfaces to determine +/// if they are the first surface. +first: bool = true, + pub const CreateError = Allocator.Error || font.SharedGridSet.InitError; /// Initialize the main app instance. This creates the main window, sets diff --git a/src/Surface.zig b/src/Surface.zig index b00672896..661ae0b3e 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -130,6 +130,11 @@ config: DerivedConfig, /// This is used to determine if we need to confirm, hold open, etc. child_exited: bool = false, +/// We maintain our focus state and assume we're focused by default. +/// If we're not initially focused then apprts can call focusCallback +/// to let us know. +focused: bool = true, + /// The effect of an input event. This can be used by callers to take /// the appropriate action after an input event. For example, key /// input can be forwarded to the OS for further processing if it @@ -469,13 +474,19 @@ pub fn init( .config = derived_config, }; + // The command we're going to execute + const command: ?[]const u8 = if (app.first) + config.@"initial-command" orelse config.command + else + config.command; + // Start our IO implementation // This separate block ({}) is important because our errdefers must // be scoped here to be valid. { // Initialize our IO backend var io_exec = try termio.Exec.init(alloc, .{ - .command = config.command, + .command = command, .shell_integration = config.@"shell-integration", .shell_integration_features = config.@"shell-integration-features", .working_directory = config.@"working-directory", @@ -613,9 +624,9 @@ pub fn init( // For xdg-terminal-exec execution we special-case and set the window // title to the command being executed. This allows window managers // to set custom styling based on the command being executed. - const command = config.command orelse break :xdg; - if (command.len > 0) { - const title = alloc.dupeZ(u8, command) catch |err| { + const v = command orelse break :xdg; + if (v.len > 0) { + const title = alloc.dupeZ(u8, v) catch |err| { log.warn( "error copying command for title, title will not be set err={}", .{err}, @@ -630,6 +641,9 @@ pub fn init( ); } } + + // We are no longer the first surface + app.first = false; } pub fn deinit(self: *Surface) void { @@ -799,6 +813,22 @@ pub fn handleMessage(self: *Surface, msg: Message) !void { }, .unlocked); }, + .color_change => |change| try self.rt_app.performAction( + .{ .surface = self }, + .color_change, + .{ + .kind = switch (change.kind) { + .background => .background, + .foreground => .foreground, + .cursor => .cursor, + .palette => |v| @enumFromInt(v), + }, + .r = change.color.r, + .g = change.color.g, + .b = change.color.b, + }, + ), + .set_mouse_shape => |shape| { log.debug("changing mouse shape: {}", .{shape}); try self.rt_app.performAction( @@ -1972,6 +2002,10 @@ pub fn focusCallback(self: *Surface, focused: bool) !void { crash.sentry.thread_state = self.crashThreadState(); defer crash.sentry.thread_state = null; + // If our focus state is the same we do nothing. + if (self.focused == focused) return; + self.focused = focused; + // Notify our render thread of the new state _ = self.renderer_thread.mailbox.push(.{ .focus = focused, @@ -2028,6 +2062,12 @@ pub fn focusCallback(self: *Surface, focused: bool) !void { // Schedule render which also drains our mailbox try self.queueRender(); + // Whenever our focus changes we unhide the mouse. The mouse will be + // hidden again if the user starts typing. This helps alleviate some + // buggy behavior upstream in macOS with the mouse never becoming visible + // again when tabbing between programs (see #2525). + self.showMouse(); + // Update the focus state and notify the terminal { self.renderer_state.mutex.lock(); @@ -2713,12 +2753,20 @@ pub fn mouseButtonCallback( } // For left button click release we check if we are moving our cursor. - if (button == .left and action == .release and mods.alt) click_move: { + if (button == .left and + action == .release and + mods.alt) + click_move: { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + + // If we have a selection then we do not do click to move because + // it means that we moved our cursor while pressing the mouse button. + if (self.io.terminal.screen.selection != null) break :click_move; + // Moving always resets the click count so that we don't highlight. self.mouse.left_click_count = 0; const pin = self.mouse.left_click_pin orelse break :click_move; - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); try self.clickMoveCursor(pin.*); return true; } @@ -3437,7 +3485,7 @@ fn dragLeftClickSingle( try self.setSelection(if (selected) terminal.Selection.init( drag_pin, drag_pin, - self.mouse.mods.ctrlOrSuper() and self.mouse.mods.alt, + SurfaceMouse.isRectangleSelectState(self.mouse.mods), ) else null); return; @@ -3472,7 +3520,7 @@ fn dragLeftClickSingle( try self.setSelection(terminal.Selection.init( start, drag_pin, - self.mouse.mods.ctrlOrSuper() and self.mouse.mods.alt, + SurfaceMouse.isRectangleSelectState(self.mouse.mods), )); return; } diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 925445fd9..f4d9cfb20 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -186,6 +186,10 @@ pub const Action = union(Key) { /// key mode because other input may be ignored. key_sequence: KeySequence, + /// A terminal color was changed programmatically through things + /// such as OSC 10/11. + color_change: ColorChange, + /// Sync with: ghostty_action_tag_e pub const Key = enum(c_int) { new_window, @@ -219,6 +223,7 @@ pub const Action = union(Key) { quit_timer, secure_input, key_sequence, + color_change, }; /// Sync with: ghostty_action_u @@ -454,3 +459,20 @@ pub const KeySequence = union(enum) { }; } }; + +pub const ColorChange = extern struct { + kind: ColorKind, + r: u8, + g: u8, + b: u8, +}; + +pub const ColorKind = enum(c_int) { + // Negative numbers indicate some named kind + foreground = -1, + background = -2, + cursor = -3, + + // 0+ values indicate a palette index + _, +}; diff --git a/src/apprt/glfw.zig b/src/apprt/glfw.zig index 1dde97c9c..638f52bab 100644 --- a/src/apprt/glfw.zig +++ b/src/apprt/glfw.zig @@ -223,6 +223,7 @@ pub const App = struct { .mouse_over_link, .cell_size, .renderer_health, + .color_change, => log.info("unimplemented action={}", .{action}), } } diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index 462e452a1..8620de57d 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -485,6 +485,7 @@ pub fn performAction( .key_sequence, .render_inspector, .renderer_health, + .color_change, => log.warn("unimplemented action={}", .{action}), } } @@ -928,6 +929,13 @@ fn loadRuntimeCss( \\ --headerbar-bg-color: rgb({d},{d},{d}); \\ --headerbar-backdrop-color: oklab(from var(--headerbar-bg-color) calc(l * 0.9) a b / alpha); \\}} + \\windowhandle {{ + \\ background-color: var(--headerbar-bg-color); + \\ color: var(--headerbar-fg-color); + \\}} + \\windowhandle:backdrop {{ + \\ background-color: var(--headerbar-backdrop-color); + \\}} , .{ headerbar_foreground.r, headerbar_foreground.g, diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index a062c415e..63a71768f 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -92,13 +92,9 @@ pub fn init(self: *Window, app: *App) !void { break :window window; } }; + errdefer c.gtk_window_destroy(@ptrCast(window)); const gtk_window: *c.GtkWindow = @ptrCast(window); - errdefer if (self.isAdwWindow()) { - c.adw_application_window_destroy(window); - } else { - c.gtk_application_window_destroy(gtk_window); - }; self.window = gtk_window; c.gtk_window_set_title(gtk_window, "Ghostty"); c.gtk_window_set_default_size(gtk_window, 1000, 600); diff --git a/src/apprt/surface.zig b/src/apprt/surface.zig index daa2ad547..07eb1d466 100644 --- a/src/apprt/surface.zig +++ b/src/apprt/surface.zig @@ -70,6 +70,12 @@ pub const Message = union(enum) { /// unless the surface exits. password_input: bool, + /// A terminal color was changed using OSC sequences. + color_change: struct { + kind: terminal.osc.Command.ColorKind, + color: terminal.color.RGB, + }, + pub const ReportTitleStyle = enum { csi_21_t, diff --git a/src/cli/action.zig b/src/cli/action.zig index 950577158..1da0c0609 100644 --- a/src/cli/action.zig +++ b/src/cli/action.zig @@ -71,6 +71,13 @@ pub const Action = enum { var pending_help: bool = false; var pending: ?Action = null; while (iter.next()) |arg| { + // If we see a "-e" and we haven't seen a command yet, then + // we are done looking for commands. This special case enables + // `ghostty -e ghostty +command`. If we've seen a command we + // still want to keep looking because + // `ghostty +command -e +command` is invalid. + if (std.mem.eql(u8, arg, "-e") and pending == null) return null; + // Special case, --version always outputs the version no // matter what, no matter what other args exist. if (std.mem.eql(u8, arg, "--version")) return .version; @@ -240,3 +247,30 @@ test "parse action plus" { try testing.expect(action.? == .version); } } + +test "parse action plus ignores -e" { + const testing = std.testing; + const alloc = testing.allocator; + + { + var iter = try std.process.ArgIteratorGeneral(.{}).init( + alloc, + "--a=42 -e +version", + ); + defer iter.deinit(); + const action = try Action.detectIter(&iter); + try testing.expect(action == null); + } + + { + var iter = try std.process.ArgIteratorGeneral(.{}).init( + alloc, + "+list-fonts --a=42 -e +version", + ); + defer iter.deinit(); + try testing.expectError( + Action.Error.MultipleActions, + Action.detectIter(&iter), + ); + } +} diff --git a/src/config/Config.zig b/src/config/Config.zig index 3afbc9fd1..6d35f1b4e 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -513,7 +513,26 @@ palette: Palette = .{}, /// arguments are provided, the command will be executed using `/bin/sh -c`. /// Ghostty does not do any shell command parsing. /// -/// If you're using the `ghostty` CLI there is also a shortcut to run a command +/// This command will be used for all new terminal surfaces, i.e. new windows, +/// tabs, etc. If you want to run a command only for the first terminal surface +/// created when Ghostty starts, use the `initial-command` configuration. +/// +/// Ghostty supports the common `-e` flag for executing a command with +/// arguments. For example, `ghostty -e fish --with --custom --args`. +/// This flag sets the `initial-command` configuration, see that for more +/// information. +command: ?[]const u8 = null, + +/// This is the same as "command", but only applies to the first terminal +/// surface created when Ghostty starts. Subsequent terminal surfaces will use +/// the `command` configuration. +/// +/// After the first terminal surface is created (or closed), there is no +/// way to run this initial command again automatically. As such, setting +/// this at runtime works but will only affect the next terminal surface +/// if it is the first one ever created. +/// +/// If you're using the `ghostty` CLI there is also a shortcut to set this /// with arguments directly: you can use the `-e` flag. For example: `ghostty -e /// fish --with --custom --args`. The `-e` flag automatically forces some /// other behaviors as well: @@ -525,7 +544,7 @@ palette: Palette = .{}, /// process will exit when the command exits. Additionally, the /// `quit-after-last-window-closed-delay` is unset. /// -command: ?[]const u8 = null, +@"initial-command": ?[]const u8 = null, /// If true, keep the terminal open after the command exits. Normally, the /// terminal window closes when the running command (such as a shell) exits. @@ -2356,7 +2375,7 @@ pub fn loadCliArgs(self: *Config, alloc_gpa: Allocator) !void { } self.@"_xdg-terminal-exec" = true; - self.command = command.items[0 .. command.items.len - 1]; + self.@"initial-command" = command.items[0 .. command.items.len - 1]; return; } } @@ -2755,7 +2774,7 @@ pub fn parseManuallyHook( return false; } - self.command = command.items[0 .. command.items.len - 1]; + self.@"initial-command" = command.items[0 .. command.items.len - 1]; // See "command" docs for the implied configurations and why. self.@"gtk-single-instance" = .false; @@ -2945,7 +2964,7 @@ test "parse e: command only" { var it: TestIterator = .{ .data = &.{"foo"} }; try testing.expect(!try cfg.parseManuallyHook(alloc, "-e", &it)); - try testing.expectEqualStrings("foo", cfg.command.?); + try testing.expectEqualStrings("foo", cfg.@"initial-command".?); } test "parse e: command and args" { @@ -2956,7 +2975,7 @@ test "parse e: command and args" { var it: TestIterator = .{ .data = &.{ "echo", "foo", "bar baz" } }; try testing.expect(!try cfg.parseManuallyHook(alloc, "-e", &it)); - try testing.expectEqualStrings("echo foo bar baz", cfg.command.?); + try testing.expectEqualStrings("echo foo bar baz", cfg.@"initial-command".?); } test "clone default" { diff --git a/src/config/sublime_syntax.zig b/src/config/sublime_syntax.zig new file mode 100644 index 000000000..1b7c4900a --- /dev/null +++ b/src/config/sublime_syntax.zig @@ -0,0 +1,51 @@ +const std = @import("std"); +const Config = @import("Config.zig"); + +const Template = struct { + const header = + \\%YAML 1.2 + \\--- + \\# See http://www.sublimetext.com/docs/syntax.html + \\name: Ghostty Config + \\file_extensions: + \\ - ghostty + \\scope: source.ghostty + \\ + \\contexts: + \\ main: + \\ # Comments + \\ - match: '#.*$' + \\ scope: comment.line.number-sign.ghostty + \\ + \\ # Keywords + \\ - match: '\b( + ; + const footer = + \\)\b' + \\ scope: keyword.other.ghostty + \\ + ; +}; + +/// Check if a field is internal (starts with underscore) +fn isInternal(name: []const u8) bool { + return name.len > 0 and name[0] == '_'; +} + +/// Generate keywords from Config fields +fn generateKeywords() []const u8 { + @setEvalBranchQuota(5000); + var keywords: []const u8 = ""; + const config_fields = @typeInfo(Config).Struct.fields; + + for (config_fields) |field| { + if (isInternal(field.name)) continue; + if (keywords.len > 0) keywords = keywords ++ "|"; + keywords = keywords ++ field.name; + } + + return keywords; +} + +/// Complete Sublime syntax file content +pub const syntax = Template.header ++ generateKeywords() ++ Template.footer; diff --git a/src/font/DeferredFace.zig b/src/font/DeferredFace.zig index db9e23623..3ee104386 100644 --- a/src/font/DeferredFace.zig +++ b/src/font/DeferredFace.zig @@ -57,6 +57,11 @@ pub const CoreText = struct { /// The initialized font font: *macos.text.Font, + /// Variations to apply to this font. We apply the variations to the + /// search descriptor but sometimes when the font collection is + /// made the variation axes are reset so we have to reapply them. + variations: []const font.face.Variation, + pub fn deinit(self: *CoreText) void { self.font.release(); self.* = undefined; @@ -194,7 +199,10 @@ fn loadCoreText( ) !Face { _ = lib; const ct = self.ct.?; - return try Face.initFontCopy(ct.font, opts); + var face = try Face.initFontCopy(ct.font, opts); + errdefer face.deinit(); + try face.setVariations(ct.variations, opts); + return face; } fn loadCoreTextFreetype( @@ -236,43 +244,7 @@ fn loadCoreTextFreetype( //std.log.warn("path={s}", .{path_slice}); var face = try Face.initFile(lib, buf[0..path_slice.len :0], 0, opts); errdefer face.deinit(); - - // If our ct font has variations, apply them to the face. - if (ct.font.copyAttribute(.variation)) |variations| vars: { - defer variations.release(); - if (variations.getCount() == 0) break :vars; - - // This configuration is just used for testing so we don't want to - // have to pass a full allocator through so use the stack. We - // shouldn't have a lot of variations and if we do we should use - // another mechanism. - // - // On macOS the default stack size for a thread is 512KB and the main - // thread gets megabytes so 16KB is a safe stack allocation. - var data: [1024 * 16]u8 = undefined; - var fba = std.heap.FixedBufferAllocator.init(&data); - const alloc = fba.allocator(); - - var face_vars = std.ArrayList(font.face.Variation).init(alloc); - const kav = try variations.getKeysAndValues(alloc); - for (kav.keys, kav.values) |key, value| { - const num: *const macos.foundation.Number = @ptrCast(key.?); - const val: *const macos.foundation.Number = @ptrCast(value.?); - - var num_i32: i32 = undefined; - if (!num.getValue(.sint32, &num_i32)) continue; - - var val_f64: f64 = undefined; - if (!val.getValue(.float64, &val_f64)) continue; - - try face_vars.append(.{ - .id = @bitCast(num_i32), - .value = val_f64, - }); - } - - try face.setVariations(face_vars.items, opts); - } + try face.setVariations(ct.variations, opts); return face; } diff --git a/src/font/discovery.zig b/src/font/discovery.zig index 3aa16eebf..e73ea626f 100644 --- a/src/font/discovery.zig +++ b/src/font/discovery.zig @@ -79,7 +79,7 @@ pub const Descriptor = struct { // This is not correct, but we don't currently depend on the // hash value being different based on decimal values of variations. - autoHash(hasher, @as(u64, @intFromFloat(variation.value))); + autoHash(hasher, @as(i64, @intFromFloat(variation.value))); } } @@ -235,21 +235,7 @@ pub const Descriptor = struct { ); } - // Build our descriptor from attrs - var desc = try macos.text.FontDescriptor.createWithAttributes(@ptrCast(attrs)); - errdefer desc.release(); - - // Variations are built by copying the descriptor. I don't know a way - // to set it on attrs directly. - for (self.variations) |v| { - const id = try macos.foundation.Number.create(.int, @ptrCast(&v.id)); - defer id.release(); - const next = try desc.createCopyWithVariation(id, v.value); - desc.release(); - desc = next; - } - - return desc; + return try macos.text.FontDescriptor.createWithAttributes(@ptrCast(attrs)); } }; @@ -384,6 +370,7 @@ pub const CoreText = struct { return DiscoverIterator{ .alloc = alloc, .list = zig_list, + .variations = desc.variations, .i = 0, }; } @@ -420,6 +407,7 @@ pub const CoreText = struct { return DiscoverIterator{ .alloc = alloc, .list = list, + .variations = desc.variations, .i = 0, }; } @@ -443,6 +431,7 @@ pub const CoreText = struct { return DiscoverIterator{ .alloc = alloc, .list = list, + .variations = desc.variations, .i = 0, }; } @@ -682,30 +671,29 @@ pub const CoreText = struct { break :style .unmatched; defer style.release(); + // Get our style string + var buf: [128]u8 = undefined; + const style_str = style.cstring(&buf, .utf8) orelse break :style .unmatched; + // If we have a specific desired style, attempt to search for that. if (desc.style) |desired_style| { - var buf: [128]u8 = undefined; - const style_str = style.cstring(&buf, .utf8) orelse break :style .unmatched; - // Matching style string gets highest score if (std.mem.eql(u8, desired_style, style_str)) break :style .match; - - // Otherwise the score is based on the length of the style string. - // Shorter styles are scored higher. - break :style @enumFromInt(100 -| style_str.len); + } else if (!desc.bold and !desc.italic) { + // If we do not, and we have no symbolic traits, then we try + // to find "regular" (or no style). If we have symbolic traits + // we do nothing but we can improve scoring by taking that into + // account, too. + if (std.mem.eql(u8, "Regular", style_str)) { + break :style .match; + } } - // If we do not, and we have no symbolic traits, then we try - // to find "regular" (or no style). If we have symbolic traits - // we do nothing but we can improve scoring by taking that into - // account, too. - if (!desc.bold and !desc.italic) { - var buf: [128]u8 = undefined; - const style_str = style.cstring(&buf, .utf8) orelse break :style .unmatched; - if (std.mem.eql(u8, "Regular", style_str)) break :style .match; - } - - break :style .unmatched; + // Otherwise the score is based on the length of the style string. + // Shorter styles are scored higher. This is a heuristic that + // if we don't have a desired style then shorter tends to be + // more often the "regular" style. + break :style @enumFromInt(100 -| style_str.len); }; score_acc.traits = traits: { @@ -721,6 +709,7 @@ pub const CoreText = struct { pub const DiscoverIterator = struct { alloc: Allocator, list: []const *macos.text.FontDescriptor, + variations: []const Variation, i: usize, pub fn deinit(self: *DiscoverIterator) void { @@ -756,7 +745,10 @@ pub const CoreText = struct { defer self.i += 1; return DeferredFace{ - .ct = .{ .font = font }, + .ct = .{ + .font = font, + .variations = self.variations, + }, }; } }; diff --git a/src/font/embedded.zig b/src/font/embedded.zig index 2f496f86a..098aa3eb4 100644 --- a/src/font/embedded.zig +++ b/src/font/embedded.zig @@ -14,6 +14,7 @@ pub const emoji = @embedFile("res/NotoColorEmoji.ttf"); pub const emoji_text = @embedFile("res/NotoEmoji-Regular.ttf"); /// Fonts with general properties +pub const arabic = @embedFile("res/KawkabMono-Regular.ttf"); pub const variable = @embedFile("res/Lilex-VF.ttf"); /// Font with nerd fonts embedded. diff --git a/src/font/face/coretext.zig b/src/font/face/coretext.zig index 2403b3902..363dbacd8 100644 --- a/src/font/face/coretext.zig +++ b/src/font/face/coretext.zig @@ -229,6 +229,9 @@ pub const Face = struct { vs: []const font.face.Variation, opts: font.face.Options, ) !void { + // If we have no variations, we don't need to do anything. + if (vs.len == 0) return; + // Create a new font descriptor with all the variations set. var desc = self.font.copyDescriptor(); defer desc.release(); diff --git a/src/font/res/KawkabMono-Regular.ttf b/src/font/res/KawkabMono-Regular.ttf new file mode 100644 index 000000000..4841678de Binary files /dev/null and b/src/font/res/KawkabMono-Regular.ttf differ diff --git a/src/font/shaper/harfbuzz.zig b/src/font/shaper/harfbuzz.zig index b3c8400b3..ccb422f20 100644 --- a/src/font/shaper/harfbuzz.zig +++ b/src/font/shaper/harfbuzz.zig @@ -190,6 +190,11 @@ pub const Shaper = struct { // Reset the buffer for our current run self.shaper.hb_buf.reset(); self.shaper.hb_buf.setContentType(.unicode); + + // We don't support RTL text because RTL in terminals is messy. + // Its something we want to improve. For now, we force LTR because + // our renderers assume a strictly increasing X value. + self.shaper.hb_buf.setDirection(.ltr); } pub fn addCodepoint(self: RunIteratorHook, cp: u32, cluster: u32) !void { @@ -453,6 +458,46 @@ test "shape monaspace ligs" { } } +// Ghostty doesn't currently support RTL and our renderers assume +// that cells are in strict LTR order. This means that we need to +// force RTL text to be LTR for rendering. This test ensures that +// we are correctly forcing RTL text to be LTR. +test "shape arabic forced LTR" { + const testing = std.testing; + const alloc = testing.allocator; + + var testdata = try testShaperWithFont(alloc, .arabic); + defer testdata.deinit(); + + var screen = try terminal.Screen.init(alloc, 120, 30, 0); + defer screen.deinit(); + try screen.testWriteString(@embedFile("testdata/arabic.txt")); + + var shaper = &testdata.shaper; + var it = shaper.runIterator( + testdata.grid, + &screen, + screen.pages.pin(.{ .screen = .{ .y = 0 } }).?, + null, + null, + ); + var count: usize = 0; + while (try it.next(alloc)) |run| { + count += 1; + try testing.expectEqual(@as(usize, 25), run.cells); + + const cells = try shaper.shape(run); + try testing.expectEqual(@as(usize, 25), cells.len); + + var x: u16 = cells[0].x; + for (cells[1..]) |cell| { + try testing.expectEqual(x + 1, cell.x); + x = cell.x; + } + } + try testing.expectEqual(@as(usize, 1), count); +} + test "shape emoji width" { const testing = std.testing; const alloc = testing.allocator; @@ -1146,6 +1191,7 @@ const TestShaper = struct { const TestFont = enum { inconsolata, monaspace_neon, + arabic, }; /// Helper to return a fully initialized shaper. @@ -1159,6 +1205,7 @@ fn testShaperWithFont(alloc: Allocator, font_req: TestFont) !TestShaper { const testFont = switch (font_req) { .inconsolata => font.embedded.inconsolata, .monaspace_neon => font.embedded.monaspace_neon, + .arabic => font.embedded.arabic, }; var lib = try Library.init(); diff --git a/src/font/shaper/testdata/arabic.txt b/src/font/shaper/testdata/arabic.txt new file mode 100644 index 000000000..d450c7623 --- /dev/null +++ b/src/font/shaper/testdata/arabic.txt @@ -0,0 +1,3 @@ +غريبه لاني عربي أبا عن جد +واتكلم الانجليزية بطلاقة اكثر من ٢٥ سنه +ومع هذا اجد العربيه افضل لان فيها الكثير من المفردات الاكثر دقه بالوصف diff --git a/src/os/hostname.zig b/src/os/hostname.zig new file mode 100644 index 000000000..6956ed71f --- /dev/null +++ b/src/os/hostname.zig @@ -0,0 +1,142 @@ +const std = @import("std"); +const posix = std.posix; + +pub const HostnameParsingError = error{ + NoHostnameInUri, + NoSpaceLeft, +}; + +/// Print the hostname from a file URI into a buffer. +pub fn bufPrintHostnameFromFileUri( + buf: []u8, + uri: std.Uri, +) HostnameParsingError![]const u8 { + // Get the raw string of the URI. Its unclear to me if the various + // tags of this enum guarantee no percent-encoding so we just + // check all of it. This isn't a performance critical path. + const host_component = uri.host orelse return error.NoHostnameInUri; + const host: []const u8 = switch (host_component) { + .raw => |v| v, + .percent_encoded => |v| v, + }; + + // When the "Private Wi-Fi address" setting is toggled on macOS the hostname + // is set to a random mac address, e.g. '12:34:56:78:90:ab'. + // The URI will be parsed as if the last set of digits is a port number, so + // we need to make sure that part is included when it's set. + + // We're only interested in special port handling when the current hostname is a + // partial MAC address that's potentially missing the last component. + // If that's not the case we just return the plain URI hostname directly. + // NOTE: This implementation is not sufficient to verify a valid mac address, but + // it's probably sufficient for this specific purpose. + if (host.len != 14 or std.mem.count(u8, host, ":") != 4) return host; + + // If we don't have a port then we can return the hostname as-is because + // it's not a partial MAC-address. + const port = uri.port orelse return host; + + // If the port is not a 2-digit number we're not looking at a partial + // MAC-address, and instead just a regular port so we return the plain + // URI hostname. + if (port < 10 or port > 99) return host; + + var fbs = std.io.fixedBufferStream(buf); + try std.fmt.format( + fbs.writer(), + "{s}:{d}", + .{ host, port }, + ); + + return fbs.getWritten(); +} + +pub const LocalHostnameValidationError = error{ + PermissionDenied, + Unexpected, +}; + +/// Checks if a hostname is local to the current machine. This matches +/// both "localhost" and the current hostname of the machine (as returned +/// by `gethostname`). +pub fn isLocalHostname(hostname: []const u8) LocalHostnameValidationError!bool { + // A 'localhost' hostname is always considered local. + if (std.mem.eql(u8, "localhost", hostname)) return true; + + // If hostname is not "localhost" it must match our hostname. + var buf: [posix.HOST_NAME_MAX]u8 = undefined; + const ourHostname = try posix.gethostname(&buf); + return std.mem.eql(u8, hostname, ourHostname); +} + +test "bufPrintHostnameFromFileUri succeeds with ascii hostname" { + const uri = try std.Uri.parse("file://localhost/"); + + var buf: [posix.HOST_NAME_MAX]u8 = undefined; + const actual = try bufPrintHostnameFromFileUri(&buf, uri); + try std.testing.expectEqualStrings("localhost", actual); +} + +test "bufPrintHostnameFromFileUri succeeds with hostname as mac address" { + const uri = try std.Uri.parse("file://12:34:56:78:90:12"); + + var buf: [posix.HOST_NAME_MAX]u8 = undefined; + const actual = try bufPrintHostnameFromFileUri(&buf, uri); + try std.testing.expectEqualStrings("12:34:56:78:90:12", actual); +} + +test "bufPrintHostnameFromFileUri returns only hostname when there is a port component in the URI" { + // First: try with a non-2-digit port, to test general port handling. + const four_port_uri = try std.Uri.parse("file://has-a-port:1234"); + + var four_port_buf: [posix.HOST_NAME_MAX]u8 = undefined; + const four_port_actual = try bufPrintHostnameFromFileUri(&four_port_buf, four_port_uri); + try std.testing.expectEqualStrings("has-a-port", four_port_actual); + + // Second: try with a 2-digit port to test mac-address handling. + const two_port_uri = try std.Uri.parse("file://has-a-port:12"); + + var two_port_buf: [posix.HOST_NAME_MAX]u8 = undefined; + const two_port_actual = try bufPrintHostnameFromFileUri(&two_port_buf, two_port_uri); + try std.testing.expectEqualStrings("has-a-port", two_port_actual); + + // Third: try with a mac-address that has a port-component added to it to test mac-address handling. + const mac_with_port_uri = try std.Uri.parse("file://12:34:56:78:90:12:1234"); + + var mac_with_port_buf: [posix.HOST_NAME_MAX]u8 = undefined; + const mac_with_port_actual = try bufPrintHostnameFromFileUri(&mac_with_port_buf, mac_with_port_uri); + try std.testing.expectEqualStrings("12:34:56:78:90:12", mac_with_port_actual); +} + +test "bufPrintHostnameFromFileUri returns NoHostnameInUri error when hostname is missing from uri" { + const uri = try std.Uri.parse("file:///"); + + var buf: [posix.HOST_NAME_MAX]u8 = undefined; + const actual = bufPrintHostnameFromFileUri(&buf, uri); + try std.testing.expectError(HostnameParsingError.NoHostnameInUri, actual); +} + +test "bufPrintHostnameFromFileUri returns NoSpaceLeft error when provided buffer has insufficient size" { + const uri = try std.Uri.parse("file://12:34:56:78:90:12/"); + + var buf: [5]u8 = undefined; + const actual = bufPrintHostnameFromFileUri(&buf, uri); + try std.testing.expectError(HostnameParsingError.NoSpaceLeft, actual); +} + +test "isLocalHostname returns true when provided hostname is localhost" { + try std.testing.expect(try isLocalHostname("localhost")); +} + +test "isLocalHostname returns true when hostname is local" { + var buf: [posix.HOST_NAME_MAX]u8 = undefined; + const localHostname = try posix.gethostname(&buf); + try std.testing.expect(try isLocalHostname(localHostname)); +} + +test "isLocalHostname returns false when hostname is not local" { + try std.testing.expectEqual( + false, + try isLocalHostname("not-the-local-hostname"), + ); +} diff --git a/src/os/main.zig b/src/os/main.zig index 7eed97445..48a712d40 100644 --- a/src/os/main.zig +++ b/src/os/main.zig @@ -17,6 +17,7 @@ const resourcesdir = @import("resourcesdir.zig"); // Namespaces pub const args = @import("args.zig"); pub const cgroup = @import("cgroup.zig"); +pub const hostname = @import("hostname.zig"); pub const passwd = @import("passwd.zig"); pub const xdg = @import("xdg.zig"); pub const windows = @import("windows.zig"); diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index f586d22b4..cb0f5a3de 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -175,9 +175,9 @@ pub const GPUState = struct { instance: InstanceBuffer, // MTLBuffer pub fn init() !GPUState { - const device = objc.Object.fromId(mtl.MTLCreateSystemDefaultDevice()); + const device = try chooseDevice(); const queue = device.msgSend(objc.Object, objc.sel("newCommandQueue"), .{}); - errdefer queue.msgSend(void, objc.sel("release"), .{}); + errdefer queue.release(); var instance = try InstanceBuffer.initFill(device, &.{ 0, 1, 3, // Top-left triangle @@ -200,13 +200,33 @@ pub const GPUState = struct { return result; } + fn chooseDevice() error{NoMetalDevice}!objc.Object { + const devices = objc.Object.fromId(mtl.MTLCopyAllDevices()); + defer devices.release(); + var chosen_device: ?objc.Object = null; + var iter = devices.iterate(); + while (iter.next()) |device| { + // We want a GPU that’s connected to a display. + if (device.getProperty(bool, "isHeadless")) continue; + chosen_device = device; + // If the user has an eGPU plugged in, they probably want + // to use it. Otherwise, integrated GPUs are better for + // battery life and thermals. + if (device.getProperty(bool, "isRemovable") or + device.getProperty(bool, "isLowPower")) break; + } + const device = chosen_device orelse return error.NoMetalDevice; + return device.retain(); + } + pub fn deinit(self: *GPUState) void { // Wait for all of our inflight draws to complete so that // we can cleanly deinit our GPU state. for (0..BufferCount) |_| self.frame_sema.wait(); for (&self.frames) |*frame| frame.deinit(); self.instance.deinit(); - self.queue.msgSend(void, objc.sel("release"), .{}); + self.queue.release(); + self.device.release(); } /// Get the next frame state to draw to. This will wait on the @@ -269,13 +289,13 @@ pub const FrameState = struct { .size = 8, .format = .grayscale, }); - errdefer deinitMTLResource(grayscale); + errdefer grayscale.release(); const color = try initAtlasTexture(device, &.{ .data = undefined, .size = 8, .format = .rgba, }); - errdefer deinitMTLResource(color); + errdefer color.release(); return .{ .uniforms = uniforms, @@ -290,8 +310,8 @@ pub const FrameState = struct { self.uniforms.deinit(); self.cells.deinit(); self.cells_bg.deinit(); - deinitMTLResource(self.grayscale); - deinitMTLResource(self.color); + self.grayscale.release(); + self.color.release(); } }; @@ -319,8 +339,8 @@ pub const CustomShaderState = struct { } pub fn deinit(self: *CustomShaderState) void { - deinitMTLResource(self.front_texture); - deinitMTLResource(self.back_texture); + self.front_texture.release(); + self.back_texture.release(); self.sampler.deinit(); } }; @@ -2057,8 +2077,8 @@ pub fn setScreenSize( // Only free our previous texture if this isn't our first // time setting the custom shader state. if (state.uniforms.resolution[0] > 0) { - deinitMTLResource(state.front_texture); - deinitMTLResource(state.back_texture); + state.front_texture.release(); + state.back_texture.release(); } state.uniforms.resolution = .{ @@ -2982,7 +3002,7 @@ fn syncAtlasTexture(device: objc.Object, atlas: *const font.Atlas, texture: *obj const width = texture.getProperty(c_ulong, "width"); if (atlas.size > width) { // Free our old texture - deinitMTLResource(texture.*); + texture.*.release(); // Reallocate texture.* = try initAtlasTexture(device, atlas); @@ -3049,12 +3069,6 @@ fn initAtlasTexture(device: objc.Object, atlas: *const font.Atlas) !objc.Object return objc.Object.fromId(id); } -/// Deinitialize a metal resource (buffer, texture, etc.) and free the -/// memory associated with it. -fn deinitMTLResource(obj: objc.Object) void { - obj.msgSend(void, objc.sel("release"), .{}); -} - test { _ = mtl_cell; } diff --git a/src/renderer/metal/api.zig b/src/renderer/metal/api.zig index 0781812ac..bd4f407cd 100644 --- a/src/renderer/metal/api.zig +++ b/src/renderer/metal/api.zig @@ -175,4 +175,4 @@ pub const MTLSize = extern struct { depth: c_ulong, }; -pub extern "c" fn MTLCreateSystemDefaultDevice() ?*anyopaque; +pub extern "c" fn MTLCopyAllDevices() ?*anyopaque; diff --git a/src/surface_mouse.zig b/src/surface_mouse.zig index 2ba88540c..cc1643a88 100644 --- a/src/surface_mouse.zig +++ b/src/surface_mouse.zig @@ -113,14 +113,19 @@ fn eligibleMouseShapeKeyEvent(physical_key: input.Key) bool { physical_key.leftOrRightAlt(); } -fn isRectangleSelectState(mods: input.Mods) bool { - return mods.ctrlOrSuper() and mods.alt; -} - fn isMouseModeOverrideState(mods: input.Mods) bool { return mods.shift; } +/// Returns true if our modifiers put us in a state where dragging +/// should cause a rectangle select. +pub fn isRectangleSelectState(mods: input.Mods) bool { + return if (comptime builtin.target.isDarwin()) + mods.alt + else + mods.ctrlOrSuper() and mods.alt; +} + test "keyToMouseShape" { const testing = std.testing; diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 882ef41c0..a2bf6d50e 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -2621,12 +2621,59 @@ pub fn plainStringUnwrapped(self: *Terminal, alloc: Allocator) ![]const u8 { return try self.screen.dumpStringAllocUnwrapped(alloc, .{ .viewport = .{} }); } -/// Full reset +/// Full reset. +/// +/// This will attempt to free the existing screen memory and allocate +/// new screens but if that fails this will reuse the existing memory +/// from the prior screens. In the latter case, memory may be wasted +/// (since its unused) but it isn't leaked. pub fn fullReset(self: *Terminal) void { - // Switch back to primary screen and clear it. We do not restore cursor - // because see the next step... - self.primaryScreen(.{ .clear_on_exit = true, .cursor_save = false }); + // Attempt to initialize new screens. + var new_primary = Screen.init( + self.screen.alloc, + self.cols, + self.rows, + self.screen.pages.explicit_max_size, + ) catch |err| { + log.warn("failed to allocate new primary screen, reusing old memory err={}", .{err}); + self.fallbackReset(); + return; + }; + const new_secondary = Screen.init( + self.secondary_screen.alloc, + self.cols, + self.rows, + 0, + ) catch |err| { + log.warn("failed to allocate new secondary screen, reusing old memory err={}", .{err}); + new_primary.deinit(); + self.fallbackReset(); + return; + }; + // If we got here, both new screens were successfully allocated + // and we can deinitialize the old screens. + self.screen.deinit(); + self.secondary_screen.deinit(); + + // Replace with the newly allocated screens. + self.screen = new_primary; + self.secondary_screen = new_secondary; + + self.resetCommonState(); +} + +fn fallbackReset(self: *Terminal) void { + // Clear existing screens without reallocation + self.primaryScreen(.{ .clear_on_exit = true, .cursor_save = false }); + self.screen.clearSelection(); + self.eraseDisplay(.scrollback, false); + self.eraseDisplay(.complete, false); + self.screen.cursorAbsolute(0, 0); + self.resetCommonState(); +} + +fn resetCommonState(self: *Terminal) void { // We set the saved cursor to null and then restore. This will force // our cursor to go back to the default which will also move the cursor // to the top-left. @@ -2640,7 +2687,6 @@ pub fn fullReset(self: *Terminal) void { self.modes = .{}; self.flags = .{}; self.tabstops.reset(TABSTOP_INTERVAL); - self.screen.clearSelection(); self.screen.kitty_keyboard = .{}; self.secondary_screen.kitty_keyboard = .{}; self.screen.protected_mode = .off; @@ -2651,9 +2697,6 @@ pub fn fullReset(self: *Terminal) void { .right = self.cols - 1, }; self.previous_char = null; - self.eraseDisplay(.scrollback, false); - self.eraseDisplay(.complete, false); - self.screen.cursorAbsolute(0, 0); self.pwd.clearRetainingCapacity(); self.status_display = .main; } diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 90a33e8b7..ab837cfd4 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -5,6 +5,7 @@ const xev = @import("xev"); const apprt = @import("../apprt.zig"); const build_config = @import("../build_config.zig"); const configpkg = @import("../config.zig"); +const internal_os = @import("../os/main.zig"); const renderer = @import("../renderer.zig"); const termio = @import("../termio.zig"); const terminal = @import("../terminal/main.zig"); @@ -1048,31 +1049,38 @@ pub const StreamHandler = struct { return; } + // RFC 793 defines port numbers as 16-bit numbers. 5 digits is sufficient to represent + // the maximum since 2^16 - 1 = 65_535. + // See https://www.rfc-editor.org/rfc/rfc793#section-3.1. + const PORT_NUMBER_MAX_DIGITS = 5; + // Make sure there is space for a max length hostname + the max number of digits. + var host_and_port_buf: [posix.HOST_NAME_MAX + PORT_NUMBER_MAX_DIGITS]u8 = undefined; + const hostname_from_uri = internal_os.hostname.bufPrintHostnameFromFileUri( + &host_and_port_buf, + uri, + ) catch |err| switch (err) { + error.NoHostnameInUri => { + log.warn("OSC 7 uri must contain a hostname: {}", .{err}); + return; + }, + error.NoSpaceLeft => |e| { + log.warn("failed to get full hostname for OSC 7 validation: {}", .{e}); + return; + }, + }; + // OSC 7 is a little sketchy because anyone can send any value from // any host (such an SSH session). The best practice terminals follow // is to valid the hostname to be local. - const host_valid = host_valid: { - const host_component = uri.host orelse break :host_valid false; - - // Get the raw string of the URI. Its unclear to me if the various - // tags of this enum guarantee no percent-encoding so we just - // check all of it. This isn't a performance critical path. - const host = switch (host_component) { - .raw => |v| v, - .percent_encoded => |v| v, - }; - if (host.len == 0 or std.mem.eql(u8, "localhost", host)) { - break :host_valid true; - } - - // Otherwise, it must match our hostname. - var buf: [posix.HOST_NAME_MAX]u8 = undefined; - const hostname = posix.gethostname(&buf) catch |err| { + const host_valid = internal_os.hostname.isLocalHostname( + hostname_from_uri, + ) catch |err| switch (err) { + error.PermissionDenied, + error.Unexpected, + => { log.warn("failed to get hostname for OSC 7 validation: {}", .{err}); - break :host_valid false; - }; - - break :host_valid std.mem.eql(u8, host, hostname); + return; + }, }; if (!host_valid) { log.warn("OSC 7 host must be local", .{}); @@ -1229,6 +1237,12 @@ pub const StreamHandler = struct { }, .{ .forever = {} }); }, } + + // Notify the surface of the color change + self.surfaceMessageWriter(.{ .color_change = .{ + .kind = kind, + .color = color, + } }); } pub fn resetColor( @@ -1247,6 +1261,11 @@ pub const StreamHandler = struct { self.terminal.flags.dirty.palette = true; self.terminal.color_palette.colors[i] = self.terminal.default_palette[i]; mask.unset(i); + + self.surfaceMessageWriter(.{ .color_change = .{ + .kind = .{ .palette = @intCast(i) }, + .color = self.terminal.color_palette.colors[i], + } }); } } else { var it = std.mem.tokenizeScalar(u8, value, ';'); @@ -1257,6 +1276,11 @@ pub const StreamHandler = struct { self.terminal.flags.dirty.palette = true; self.terminal.color_palette.colors[i] = self.terminal.default_palette[i]; mask.unset(i); + + self.surfaceMessageWriter(.{ .color_change = .{ + .kind = .{ .palette = @intCast(i) }, + .color = self.terminal.color_palette.colors[i], + } }); } } } @@ -1266,18 +1290,35 @@ pub const StreamHandler = struct { _ = self.renderer_mailbox.push(.{ .foreground_color = self.foreground_color, }, .{ .forever = {} }); + + self.surfaceMessageWriter(.{ .color_change = .{ + .kind = .foreground, + .color = self.foreground_color, + } }); }, .background => { self.background_color = self.default_background_color; _ = self.renderer_mailbox.push(.{ .background_color = self.background_color, }, .{ .forever = {} }); + + self.surfaceMessageWriter(.{ .color_change = .{ + .kind = .background, + .color = self.background_color, + } }); }, .cursor => { self.cursor_color = self.default_cursor_color; _ = self.renderer_mailbox.push(.{ .cursor_color = self.cursor_color, }, .{ .forever = {} }); + + if (self.cursor_color) |color| { + self.surfaceMessageWriter(.{ .color_change = .{ + .kind = .cursor, + .color = color, + } }); + } }, } }