From 6e11d947e7ae6f37567faddb863b48107cdf278b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 1 May 2025 09:29:34 -0700 Subject: [PATCH] Binding for toggling window float on top (macOS only) This adds a keybinding and apprt action for #7237. --- include/ghostty.h | 9 +++ macos/Sources/App/macOS/AppDelegate.swift | 55 ++++++++------- macos/Sources/Ghostty/Ghostty.App.swift | 40 +++++++++++ macos/Sources/Ghostty/Package.swift | 22 ++++++ src/Surface.zig | 6 ++ src/apprt/action.zig | 11 +++ src/apprt/glfw.zig | 1 + src/apprt/gtk/App.zig | 1 + src/input/Binding.zig | 82 +++++++++++++---------- src/input/command.zig | 6 ++ 10 files changed, 173 insertions(+), 60 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index f7504eb7e..18c547910 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -429,6 +429,13 @@ typedef enum { GHOSTTY_FULLSCREEN_NON_NATIVE_PADDED_NOTCH, } ghostty_action_fullscreen_e; +// apprt.action.FloatWindow +typedef enum { + GHOSTTY_FLOAT_WINDOW_ON, + GHOSTTY_FLOAT_WINDOW_OFF, + GHOSTTY_FLOAT_WINDOW_TOGGLE, +} ghostty_action_float_window_e; + // apprt.action.SecureInput typedef enum { GHOSTTY_SECURE_INPUT_ON, @@ -610,6 +617,7 @@ typedef enum { GHOSTTY_ACTION_RENDERER_HEALTH, GHOSTTY_ACTION_OPEN_CONFIG, GHOSTTY_ACTION_QUIT_TIMER, + GHOSTTY_ACTION_FLOAT_WINDOW, GHOSTTY_ACTION_SECURE_INPUT, GHOSTTY_ACTION_KEY_SEQUENCE, GHOSTTY_ACTION_COLOR_CHANGE, @@ -638,6 +646,7 @@ typedef union { ghostty_action_mouse_over_link_s mouse_over_link; ghostty_action_renderer_health_e renderer_health; ghostty_action_quit_timer_e quit_timer; + ghostty_action_float_window_e float_window; ghostty_action_secure_input_e secure_input; ghostty_action_key_sequence_s key_sequence; ghostty_action_color_change_s color_change; diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 169f57ff2..a3a3185d9 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -414,6 +414,7 @@ class AppDelegate: NSObject, syncMenuShortcut(config, action: "prompt_surface_title", menuItem: self.menuChangeTitle) 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) syncMenuShortcut(config, action: "inspector:toggle", menuItem: self.menuTerminalInspector) syncMenuShortcut(config, action: "toggle_command_palette", menuItem: self.menuCommandPalette) @@ -506,13 +507,7 @@ class AppDelegate: NSObject, } @objc private func windowDidBecomeKey(_ notification: Notification) { - guard let terminal = notification.object as? TerminalWindow else { - // If some other window became key we always turn this off - self.menuFloatOnTop?.state = .off - return - } - - self.menuFloatOnTop?.state = terminal.level == .floating ? .on : .off + syncFloatOnTopMenu(notification.object as? NSWindow) } @objc private func quickTerminalDidChangeVisibility(_ notification: Notification) { @@ -862,22 +857,6 @@ class AppDelegate: NSObject, hiddenState = nil } - @IBAction func floatOnTop(_ menuItem: NSMenuItem) { - menuItem.state = menuItem.state == .on ? .off : .on - guard let window = NSApp.keyWindow else { return } - window.level = menuItem.state == .on ? .floating : .normal - } - - @IBAction func useAsDefault(_ sender: NSMenuItem) { - let ud = UserDefaults.standard - let key = TerminalWindow.defaultLevelKey - if (menuFloatOnTop?.state == .on) { - ud.set(NSWindow.Level.floating, forKey: key) - } else { - ud.removeObject(forKey: key) - } - } - @IBAction func bringAllToFront(_ sender: Any) { if !NSApp.isActive { NSApp.activate(ignoringOtherApps: true) @@ -934,6 +913,36 @@ class AppDelegate: NSObject, } } +// MARK: Floating Windows + +extension AppDelegate { + func syncFloatOnTopMenu(_ window: NSWindow?) { + guard let window = (window ?? NSApp.keyWindow) as? TerminalWindow else { + // If some other window became key we always turn this off + self.menuFloatOnTop?.state = .off + return + } + + self.menuFloatOnTop?.state = window.level == .floating ? .on : .off + } + + @IBAction func floatOnTop(_ menuItem: NSMenuItem) { + menuItem.state = menuItem.state == .on ? .off : .on + guard let window = NSApp.keyWindow else { return } + window.level = menuItem.state == .on ? .floating : .normal + } + + @IBAction func useAsDefault(_ sender: NSMenuItem) { + let ud = UserDefaults.standard + let key = TerminalWindow.defaultLevelKey + if (menuFloatOnTop?.state == .on) { + ud.set(NSWindow.Level.floating, forKey: key) + } else { + ud.removeObject(forKey: key) + } + } +} + // MARK: NSMenuItemValidation extension AppDelegate: NSMenuItemValidation { diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 677129960..c06287087 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -496,6 +496,9 @@ extension Ghostty { case GHOSTTY_ACTION_OPEN_CONFIG: ghostty_config_open() + case GHOSTTY_ACTION_FLOAT_WINDOW: + toggleFloatWindow(app, target: target, mode: action.action.float_window) + case GHOSTTY_ACTION_SECURE_INPUT: toggleSecureInput(app, target: target, mode: action.action.secure_input) @@ -1026,6 +1029,43 @@ extension Ghostty { } } + private static func toggleFloatWindow( + _ app: ghostty_app_t, + target: ghostty_target_s, + mode mode_raw: ghostty_action_float_window_e + ) { + guard let mode = SetFloatWIndow.from(mode_raw) else { return } + + switch (target.tag) { + case GHOSTTY_TARGET_APP: + Ghostty.logger.warning("toggle float window does nothing with an app target") + return + + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return } + guard let surfaceView = self.surfaceView(from: surface) else { return } + guard let window = surfaceView.window as? TerminalWindow else { return } + + switch (mode) { + case .on: + window.level = .floating + + case .off: + window.level = .normal + + case .toggle: + window.level = window.level == .floating ? .normal : .floating + } + + if let appDelegate = NSApplication.shared.delegate as? AppDelegate { + appDelegate.syncFloatOnTopMenu(window) + } + + default: + assertionFailure() + } + } + private static func toggleSecureInput( _ app: ghostty_app_t, target: ghostty_target_s, diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index e2c770899..366bb8113 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -42,6 +42,28 @@ extension Ghostty { // MARK: Swift Types for C Types extension Ghostty { + enum SetFloatWIndow { + case on + case off + case toggle + + static func from(_ c: ghostty_action_float_window_e) -> Self? { + switch (c) { + case GHOSTTY_FLOAT_WINDOW_ON: + return .on + + case GHOSTTY_FLOAT_WINDOW_OFF: + return .off + + case GHOSTTY_FLOAT_WINDOW_TOGGLE: + return .toggle + + default: + return nil + } + } + } + enum SetSecureInput { case on case off diff --git a/src/Surface.zig b/src/Surface.zig index c776fed36..6e62f6639 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -4289,6 +4289,12 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool {}, ), + .toggle_window_float_on_top => return try self.rt_app.performAction( + .{ .surface = self }, + .float_window, + .toggle, + ), + .toggle_secure_input => return try self.rt_app.performAction( .{ .surface = self }, .secure_input, diff --git a/src/apprt/action.zig b/src/apprt/action.zig index da0ebf8e6..4be296f09 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -205,6 +205,10 @@ pub const Action = union(Key) { /// happen and can be ignored or cause a restart it isn't that important. quit_timer: QuitTimer, + /// Set the window floating state. A floating window is one that is + /// always on top of other windows even when not focused. + float_window: FloatWindow, + /// Set the secure input functionality on or off. "Secure input" means /// that the user is currently at some sort of prompt where they may be /// entering a password or other sensitive information. This can be used @@ -289,6 +293,7 @@ pub const Action = union(Key) { renderer_health, open_config, quit_timer, + float_window, secure_input, key_sequence, color_change, @@ -425,6 +430,12 @@ pub const Fullscreen = enum(c_int) { macos_non_native_padded_notch, }; +pub const FloatWindow = enum(c_int) { + on, + off, + toggle, +}; + pub const SecureInput = enum(c_int) { on, off, diff --git a/src/apprt/glfw.zig b/src/apprt/glfw.zig index 66b994051..9d1c8a6b5 100644 --- a/src/apprt/glfw.zig +++ b/src/apprt/glfw.zig @@ -235,6 +235,7 @@ pub const App = struct { .inspector, .render_inspector, .quit_timer, + .float_window, .secure_input, .key_sequence, .desktop_notification, diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index 5373e578c..2de22d8c2 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -488,6 +488,7 @@ pub fn performAction( // Unimplemented .close_all_windows, + .float_window, .toggle_command_palette, .toggle_visibility, .cell_size, diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 1a2961a53..10e16f1fe 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -222,12 +222,12 @@ pub fn lessThan(_: void, lhs: Binding, rhs: Binding) bool { pub const Action = union(enum) { /// Ignore this key combination, don't send it to the child process, just /// black hole it. - ignore: void, + ignore, /// This action is used to flag that the binding should be removed from /// the set. This should never exist in an active set and `set.put` has an /// assertion to verify this. - unbind: void, + unbind, /// Send a CSI sequence. The value should be the CSI sequence without the /// CSI header (`ESC [` or `\x1b[`). @@ -252,35 +252,35 @@ pub const Action = union(enum) { /// If you do this while in a TUI program such as vim, this may break /// the program. If you do this while in a shell, you may have to press /// enter after to get a new prompt. - reset: void, + reset, /// Copy and paste. - copy_to_clipboard: void, - paste_from_clipboard: void, - paste_from_selection: void, + copy_to_clipboard, + paste_from_clipboard, + paste_from_selection, /// Copy the URL under the cursor to the clipboard. If there is no /// URL under the cursor, this does nothing. - copy_url_to_clipboard: void, + copy_url_to_clipboard, /// Increase/decrease the font size by a certain amount. increase_font_size: f32, decrease_font_size: f32, /// Reset the font size to the original configured size. - reset_font_size: void, + reset_font_size, /// Clear the screen. This also clears all scrollback. - clear_screen: void, + clear_screen, /// Select all text on the screen. - select_all: void, + select_all, /// Scroll the screen varying amounts. - scroll_to_top: void, - scroll_to_bottom: void, - scroll_page_up: void, - scroll_page_down: void, + scroll_to_top, + scroll_to_bottom, + scroll_page_up, + scroll_page_down, scroll_page_fractional: f32, scroll_page_lines: i16, @@ -321,19 +321,19 @@ pub const Action = union(enum) { /// Open a new window. If the application isn't currently focused, /// this will bring it to the front. - new_window: void, + new_window, /// Open a new tab. - new_tab: void, + new_tab, /// Go to the previous tab. - previous_tab: void, + previous_tab, /// Go to the next tab. - next_tab: void, + next_tab, /// Go to the last tab (the one with the highest index) - last_tab: void, + last_tab, /// Go to the tab with the specific number, 1-indexed. If the tab number /// is higher than the number of tabs, this will go to the last tab. @@ -346,10 +346,10 @@ pub const Action = union(enum) { /// Toggle the tab overview. /// This only works with libadwaita enabled currently. - toggle_tab_overview: void, + toggle_tab_overview, /// Change the title of the current focused surface via a prompt. - prompt_surface_title: void, + prompt_surface_title, /// Create a new split in the given direction. /// @@ -365,7 +365,7 @@ pub const Action = union(enum) { goto_split: SplitFocusDirection, /// zoom/unzoom the current split. - toggle_split_zoom: void, + toggle_split_zoom, /// Resize the current split in a given direction. /// @@ -378,12 +378,12 @@ pub const Action = union(enum) { resize_split: SplitResizeParameter, /// Equalize all splits in the current window - equalize_splits: void, + equalize_splits, /// Reset the window to the default size. The "default size" is the /// size that a new window would be created with. This has no effect /// if the window is fullscreen. - reset_window_size: void, + reset_window_size, /// Control the terminal inspector visibility. /// @@ -397,39 +397,46 @@ pub const Action = union(enum) { /// Open the configuration file in the default OS editor. If your default OS /// editor isn't configured then this will fail. Currently, any failures to /// open the configuration will show up only in the logs. - open_config: void, + open_config, /// Reload the configuration. The exact meaning depends on the app runtime /// in use but this usually involves re-reading the configuration file /// and applying any changes. Note that not all changes can be applied at /// runtime. - reload_config: void, + reload_config, /// Close the current "surface", whether that is a window, tab, split, etc. /// This only closes ONE surface. This will trigger close confirmation as /// configured. - close_surface: void, + close_surface, /// Close the current tab, regardless of how many splits there may be. /// This will trigger close confirmation as configured. - close_tab: void, + close_tab, /// Close the window, regardless of how many tabs or splits there may be. /// This will trigger close confirmation as configured. - close_window: void, + close_window, /// Close all windows. This will trigger close confirmation as configured. /// This only works for macOS currently. - close_all_windows: void, + close_all_windows, /// Toggle maximized window state. This only works on Linux. - toggle_maximize: void, + toggle_maximize, /// Toggle fullscreen mode of window. - toggle_fullscreen: void, + toggle_fullscreen, /// Toggle window decorations on and off. This only works on Linux. - toggle_window_decorations: void, + toggle_window_decorations, + + /// Toggle whether the terminal window is always on top of other + /// windows even when it is not focused. Terminal windows always start + /// as normal (not always on top) windows. + /// + /// This only works on macOS. + toggle_window_float_on_top, /// Toggle secure input mode on or off. This is used to prevent apps /// that monitor input from seeing what you type. This is useful for @@ -439,7 +446,7 @@ pub const Action = union(enum) { /// terminal. You must toggle it off to disable it, or quit Ghostty. /// /// This only works on macOS, since this is a system API on macOS. - toggle_secure_input: void, + toggle_secure_input, /// Toggle the command palette. The command palette is a UI element /// that lets you see what actions you can perform, their associated @@ -488,7 +495,7 @@ pub const Action = union(enum) { /// plugin enabled, open System Settings > Apps & Windows > Window /// Management > Desktop Effects, and enable the plugin in the plugin list. /// Ghostty would then need to be restarted for this to take effect. - toggle_quick_terminal: void, + toggle_quick_terminal, /// Show/hide all windows. If all windows become shown, we also ensure /// Ghostty becomes focused. When hiding all windows, focus is yielded @@ -497,10 +504,10 @@ pub const Action = union(enum) { /// Note: When the focused surface is fullscreen, this method does nothing. /// /// This currently only works on macOS. - toggle_visibility: void, + toggle_visibility, /// Quit ghostty. - quit: void, + quit, /// Crash ghostty in the desired thread for the focused surface. /// @@ -797,6 +804,7 @@ pub const Action = union(enum) { .toggle_maximize, .toggle_fullscreen, .toggle_window_decorations, + .toggle_window_float_on_top, .toggle_secure_input, .toggle_command_palette, .reset_window_size, diff --git a/src/input/command.zig b/src/input/command.zig index c757736c7..701d537a1 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -346,6 +346,12 @@ fn actionCommands(action: Action.Key) []const Command { .description = "Toggle the window decorations.", }}, + .toggle_window_float_on_top => comptime &.{.{ + .action = .toggle_window_float_on_top, + .title = "Toggle Float on Top", + .description = "Toggle the float on top state of the current window.", + }}, + .toggle_secure_input => comptime &.{.{ .action = .toggle_secure_input, .title = "Toggle Secure Input",