diff --git a/include/ghostty.h b/include/ghostty.h index cb8646560..702a88ecc 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -584,6 +584,12 @@ typedef struct { const char* title; } ghostty_action_set_title_s; +// apprt.action.PromptTitle +typedef enum { + GHOSTTY_PROMPT_TITLE_SURFACE, + GHOSTTY_PROMPT_TITLE_TAB, +} ghostty_action_prompt_title_e; + // apprt.action.Pwd.C typedef struct { const char* pwd; @@ -831,7 +837,7 @@ typedef enum { GHOSTTY_ACTION_END_SEARCH, GHOSTTY_ACTION_SEARCH_TOTAL, GHOSTTY_ACTION_SEARCH_SELECTED, -} ghostty_action_tag_e; + } ghostty_action_tag_e; typedef union { ghostty_action_split_direction_e new_split; @@ -847,6 +853,7 @@ typedef union { ghostty_action_inspector_e inspector; ghostty_action_desktop_notification_s desktop_notification; ghostty_action_set_title_s set_title; + ghostty_action_prompt_title_e prompt_title; ghostty_action_pwd_s pwd; ghostty_action_mouse_shape_e mouse_shape; ghostty_action_mouse_visibility_e mouse_visibility; diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 192135c15..8baee3d89 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -68,6 +68,7 @@ class AppDelegate: NSObject, @IBOutlet private var menuDecreaseFontSize: NSMenuItem? @IBOutlet private var menuResetFontSize: NSMenuItem? @IBOutlet private var menuChangeTitle: NSMenuItem? + @IBOutlet private var menuChangeTabTitle: NSMenuItem? @IBOutlet private var menuQuickTerminal: NSMenuItem? @IBOutlet private var menuTerminalInspector: NSMenuItem? @IBOutlet private var menuCommandPalette: NSMenuItem? @@ -541,7 +542,7 @@ class AppDelegate: NSObject, self.menuDecreaseFontSize?.setImageIfDesired(systemSymbolName: "textformat.size.smaller") self.menuCommandPalette?.setImageIfDesired(systemSymbolName: "filemenu.and.selection") self.menuQuickTerminal?.setImageIfDesired(systemSymbolName: "apple.terminal") - self.menuChangeTitle?.setImageIfDesired(systemSymbolName: "pencil.line") + self.menuChangeTabTitle?.setImageIfDesired(systemSymbolName: "pencil.line") self.menuTerminalInspector?.setImageIfDesired(systemSymbolName: "scope") self.menuToggleFullScreen?.setImageIfDesired(systemSymbolName: "square.arrowtriangle.4.outward") self.menuToggleVisibility?.setImageIfDesired(systemSymbolName: "eye") @@ -609,6 +610,7 @@ class AppDelegate: NSObject, syncMenuShortcut(config, action: "decrease_font_size:1", menuItem: self.menuDecreaseFontSize) syncMenuShortcut(config, action: "reset_font_size", menuItem: self.menuResetFontSize) syncMenuShortcut(config, action: "prompt_surface_title", menuItem: self.menuChangeTitle) + syncMenuShortcut(config, action: "prompt_tab_title", menuItem: self.menuChangeTabTitle) syncMenuShortcut(config, action: "toggle_quick_terminal", menuItem: self.menuQuickTerminal) syncMenuShortcut(config, action: "toggle_visibility", menuItem: self.menuToggleVisibility) syncMenuShortcut(config, action: "toggle_window_float_on_top", menuItem: self.menuFloatOnTop) diff --git a/macos/Sources/App/macOS/MainMenu.xib b/macos/Sources/App/macOS/MainMenu.xib index 3e1084cd7..d009b9c62 100644 --- a/macos/Sources/App/macOS/MainMenu.xib +++ b/macos/Sources/App/macOS/MainMenu.xib @@ -16,6 +16,7 @@ + @@ -315,7 +316,13 @@ - + + + + + + + diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 1c8e258f7..6336f0f55 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -81,6 +81,15 @@ class BaseTerminalController: NSWindowController, /// The cancellables related to our focused surface. private var focusedSurfaceCancellables: Set = [] + /// An override title for the tab/window set by the user via prompt_tab_title. + /// When set, this takes precedence over the computed title from the terminal. + var titleOverride: String? = nil { + didSet { applyTitleToWindow() } + } + + /// The last computed title from the focused surface (without the override). + private var lastComputedTitle: String = "👻" + /// The time that undo/redo operations that contain running ptys are valid for. var undoExpiration: Duration { ghostty.config.undoTimeout @@ -325,6 +334,37 @@ class BaseTerminalController: NSWindowController, self.alert = alert } + /// Prompt the user to change the tab/window title. + func promptTabTitle() { + guard let window else { return } + + let alert = NSAlert() + alert.messageText = "Change Tab Title" + alert.informativeText = "Leave blank to restore the default." + alert.alertStyle = .informational + + let textField = NSTextField(frame: NSRect(x: 0, y: 0, width: 250, height: 24)) + textField.stringValue = titleOverride ?? window.title + alert.accessoryView = textField + + alert.addButton(withTitle: "OK") + alert.addButton(withTitle: "Cancel") + + alert.window.initialFirstResponder = textField + + alert.beginSheetModal(for: window) { [weak self] response in + guard let self else { return } + guard response == .alertFirstButtonReturn else { return } + + let newTitle = textField.stringValue + if newTitle.isEmpty { + self.titleOverride = nil + } else { + self.titleOverride = newTitle + } + } + } + /// Close a surface from a view. func closeSurface( _ view: Ghostty.SurfaceView, @@ -718,10 +758,13 @@ class BaseTerminalController: NSWindowController, } private func titleDidChange(to: String) { + lastComputedTitle = to + applyTitleToWindow() + } + + private func applyTitleToWindow() { guard let window else { return } - - // Set the main window title - window.title = to + window.title = titleOverride ?? lastComputedTitle } func pwdDidChange(to: URL?) { @@ -1017,6 +1060,10 @@ class BaseTerminalController: NSWindowController, window.performClose(sender) } + @IBAction func changeTabTitle(_ sender: Any) { + promptTabTitle() + } + @IBAction func splitRight(_ sender: Any) { guard let surface = focusedSurface?.surface else { return } ghostty.split(surface: surface, direction: GHOSTTY_SPLIT_DIRECTION_RIGHT) diff --git a/macos/Sources/Features/Terminal/TerminalRestorable.swift b/macos/Sources/Features/Terminal/TerminalRestorable.swift index ce13f2620..425f7ffb1 100644 --- a/macos/Sources/Features/Terminal/TerminalRestorable.swift +++ b/macos/Sources/Features/Terminal/TerminalRestorable.swift @@ -4,18 +4,20 @@ import Cocoa class TerminalRestorableState: Codable { static let selfKey = "state" static let versionKey = "version" - static let version: Int = 6 + static let version: Int = 7 let focusedSurface: String? let surfaceTree: SplitTree let effectiveFullscreenMode: FullscreenMode? let tabColor: TerminalTabColor + let titleOverride: String? init(from controller: TerminalController) { self.focusedSurface = controller.focusedSurface?.id.uuidString self.surfaceTree = controller.surfaceTree self.effectiveFullscreenMode = controller.fullscreenStyle?.fullscreenMode self.tabColor = (controller.window as? TerminalWindow)?.tabColor ?? .none + self.titleOverride = controller.titleOverride } init?(coder aDecoder: NSCoder) { @@ -34,6 +36,7 @@ class TerminalRestorableState: Codable { self.focusedSurface = v.value.focusedSurface self.effectiveFullscreenMode = v.value.effectiveFullscreenMode self.tabColor = v.value.tabColor + self.titleOverride = v.value.titleOverride } func encode(with coder: NSCoder) { @@ -100,6 +103,9 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration { // Restore our tab color (window as? TerminalWindow)?.tabColor = state.tabColor + // Restore the tab title override + c.titleOverride = state.titleOverride + // Setup our restored state on the controller // Find the focused surface in surfaceTree if let focusedStr = state.focusedSurface { diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 5bbf9322d..d04d7001c 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -668,6 +668,7 @@ private struct TabColorIndicatorView: View { extension TerminalWindow { private static let closeTabsOnRightMenuItemIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.closeTabsOnTheRightMenuItem") + private static let changeTitleMenuItemIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.changeTitleMenuItem") private static let tabColorSeparatorIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.tabColorSeparator") private static let tabColorPaletteIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.tabColorPalette") @@ -701,7 +702,7 @@ extension TerminalWindow { } } - appendTabColorSection(to: menu, target: targetController) + appendTabModifierSection(to: menu, target: targetController) } private func isTabContextMenu(_ menu: NSMenu) -> Bool { @@ -719,9 +720,10 @@ extension TerminalWindow { return !selectorNames.isDisjoint(with: tabContextSelectors) } - private func appendTabColorSection(to menu: NSMenu, target: TerminalController?) { + private func appendTabModifierSection(to menu: NSMenu, target: TerminalController?) { menu.removeItems(withIdentifiers: [ Self.tabColorSeparatorIdentifier, + Self.changeTitleMenuItemIdentifier, Self.tabColorPaletteIdentifier ]) @@ -729,6 +731,13 @@ extension TerminalWindow { separator.identifier = Self.tabColorSeparatorIdentifier menu.addItem(separator) + // Change Title... + let changeTitleItem = NSMenuItem(title: "Change Title...", action: #selector(BaseTerminalController.changeTabTitle(_:)), keyEquivalent: "") + changeTitleItem.identifier = Self.changeTitleMenuItemIdentifier + changeTitleItem.target = target + changeTitleItem.setImageIfDesired(systemSymbolName: "pencil.line") + menu.addItem(changeTitleItem) + let paletteItem = NSMenuItem() paletteItem.identifier = Self.tabColorPaletteIdentifier paletteItem.view = makeTabColorPaletteView( diff --git a/macos/Sources/Ghostty/Ghostty.Action.swift b/macos/Sources/Ghostty/Ghostty.Action.swift index 8fce2199d..9eb7a8e46 100644 --- a/macos/Sources/Ghostty/Ghostty.Action.swift +++ b/macos/Sources/Ghostty/Ghostty.Action.swift @@ -127,6 +127,20 @@ extension Ghostty.Action { } } } + + enum PromptTitle { + case surface + case tab + + init(_ c: ghostty_action_prompt_title_e) { + switch c { + case GHOSTTY_PROMPT_TITLE_TAB: + self = .tab + default: + self = .surface + } + } + } } // Putting the initializer in an extension preserves the automatic one. diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index f6452e54e..aff3edbc7 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -523,7 +523,7 @@ extension Ghostty { setTitle(app, target: target, v: action.action.set_title) case GHOSTTY_ACTION_PROMPT_TITLE: - return promptTitle(app, target: target) + return promptTitle(app, target: target, v: action.action.prompt_title) case GHOSTTY_ACTION_PWD: pwdChanged(app, target: target, v: action.action.pwd) @@ -1350,22 +1350,50 @@ extension Ghostty { private static func promptTitle( _ app: ghostty_app_t, - target: ghostty_target_s) -> Bool { - switch (target.tag) { - case GHOSTTY_TARGET_APP: - Ghostty.logger.warning("set title prompt does nothing with an app target") - return false + target: ghostty_target_s, + v: ghostty_action_prompt_title_e) -> Bool { + let promptTitle = Action.PromptTitle(v) + switch promptTitle { + case .surface: + switch (target.tag) { + case GHOSTTY_TARGET_APP: + Ghostty.logger.warning("set title prompt does nothing with an app target") + return false - case GHOSTTY_TARGET_SURFACE: - guard let surface = target.target.surface else { return false } - guard let surfaceView = self.surfaceView(from: surface) else { return false } - surfaceView.promptTitle() + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return false } + guard let surfaceView = self.surfaceView(from: surface) else { return false } + surfaceView.promptTitle() + return true - default: - assertionFailure() + default: + assertionFailure() + return false + } + + case .tab: + switch (target.tag) { + case GHOSTTY_TARGET_APP: + guard let window = NSApp.mainWindow ?? NSApp.keyWindow, + let controller = window.windowController as? BaseTerminalController + else { return false } + controller.promptTabTitle() + return true + + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return false } + guard let surfaceView = self.surfaceView(from: surface) else { return false } + guard let window = surfaceView.window, + let controller = window.windowController as? BaseTerminalController + else { return false } + controller.promptTabTitle() + return true + + default: + assertionFailure() + return false + } } - - return true } private static func pwdChanged( diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index e86df4454..130df6f44 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -1417,8 +1417,9 @@ extension Ghostty { item = menu.addItem(withTitle: "Toggle Terminal Inspector", action: #selector(toggleTerminalInspector(_:)), keyEquivalent: "") item.setImageIfDesired(systemSymbolName: "scope") menu.addItem(.separator()) - item = menu.addItem(withTitle: "Change Title...", action: #selector(changeTitle(_:)), keyEquivalent: "") + item = menu.addItem(withTitle: "Change Tab Title...", action: #selector(BaseTerminalController.changeTabTitle(_:)), keyEquivalent: "") item.setImageIfDesired(systemSymbolName: "pencil.line") + item = menu.addItem(withTitle: "Change Terminal Title...", action: #selector(changeTitle(_:)), keyEquivalent: "") return menu } diff --git a/src/Surface.zig b/src/Surface.zig index 9e7ad0b97..8cd8d253b 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -5183,7 +5183,13 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .prompt_surface_title => return try self.rt_app.performAction( .{ .surface = self }, .prompt_title, - {}, + .surface, + ), + + .prompt_tab_title => return try self.rt_app.performAction( + .{ .surface = self }, + .prompt_title, + .tab, ), .clear_screen => { diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 365f525f8..94965d38c 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -189,8 +189,9 @@ pub const Action = union(Key) { set_title: SetTitle, /// Set the title of the target to a prompted value. It is up to - /// the apprt to prompt. - prompt_title, + /// the apprt to prompt. The value specifies whether to prompt for the + /// surface title or the tab title. + prompt_title: PromptTitle, /// The current working directory has changed for the target terminal. pwd: Pwd, @@ -536,6 +537,12 @@ pub const MouseVisibility = enum(c_int) { hidden, }; +/// Whether to prompt for the surface title or tab title. +pub const PromptTitle = enum(c_int) { + surface, + tab, +}; + pub const MouseOverLink = struct { url: [:0]const u8, diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index 52a9f1a35..47c2972ac 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -693,7 +693,7 @@ pub const Application = extern struct { .progress_report => return Action.progressReport(target, value), - .prompt_title => return Action.promptTitle(target), + .prompt_title => return Action.promptTitle(target, value), .quit => self.quit(), @@ -2250,12 +2250,18 @@ const Action = struct { }; } - pub fn promptTitle(target: apprt.Target) bool { - switch (target) { - .app => return false, - .surface => |v| { - v.rt_surface.surface.promptTitle(); - return true; + pub fn promptTitle(target: apprt.Target, value: apprt.action.PromptTitle) bool { + switch (value) { + .surface => switch (target) { + .app => return false, + .surface => |v| { + v.rt_surface.surface.promptTitle(); + return true; + }, + }, + .tab => { + // GTK does not yet support tab title prompting + return false; }, } } diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 66fe03651..e1c636ab7 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -519,6 +519,11 @@ pub const Action = union(enum) { /// version can be found by running `ghostty +version`. prompt_surface_title, + /// Change the title of the current tab/window via a pop-up prompt. The + /// title set via this prompt overrides any title set by the terminal + /// and persists across focus changes within the tab. + prompt_tab_title, + /// Create a new split in the specified direction. /// /// Valid arguments: @@ -1191,6 +1196,7 @@ pub const Action = union(enum) { .reset_font_size, .set_font_size, .prompt_surface_title, + .prompt_tab_title, .clear_screen, .select_all, .scroll_to_top, diff --git a/src/input/command.zig b/src/input/command.zig index b3f9e86b6..639fc6e39 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -413,10 +413,16 @@ fn actionCommands(action: Action.Key) []const Command { .prompt_surface_title => comptime &.{.{ .action = .prompt_surface_title, - .title = "Change Title...", + .title = "Change Terminal Title...", .description = "Prompt for a new title for the current terminal.", }}, + .prompt_tab_title => comptime &.{.{ + .action = .prompt_tab_title, + .title = "Change Tab Title...", + .description = "Prompt for a new title for the current tab.", + }}, + .new_split => comptime &.{ .{ .action = .{ .new_split = .left },