diff --git a/.github/scripts/request_review.py b/.github/scripts/request_review.py deleted file mode 100644 index d799e7c58..000000000 --- a/.github/scripts/request_review.py +++ /dev/null @@ -1,189 +0,0 @@ -# /// script -# requires-python = ">=3.9" -# dependencies = [ -# "githubkit", -# "loguru", -# ] -# /// - -from __future__ import annotations - -import asyncio -import os -import re -import sys -from collections.abc import Iterator -from contextlib import contextmanager -from itertools import chain - -from githubkit import GitHub -from githubkit.exception import RequestFailed -from loguru import logger - -ORG_NAME = "ghostty-org" -REPO_NAME = "ghostty" -ALLOWED_PARENT_TEAM = "localization" -LOCALIZATION_TEAM_NAME_PATTERN = re.compile(r"[a-z]{2}_[A-Z]{2}") -LEVEL_MAP = {"DEBUG": "DBG", "WARNING": "WRN", "ERROR": "ERR"} - -logger.remove() -logger.add( - sys.stderr, - format=lambda record: ( - "{time:YYYY-MM-DD HH:mm:ss.SSS} | " - f"{LEVEL_MAP[record['level'].name]} | " - "{function}:{line} - " - "{message}\n" - ), - backtrace=True, - diagnose=True, -) - - -@contextmanager -def log_fail(message: str, *, die: bool = True) -> Iterator[None]: - try: - yield - except RequestFailed as exc: - logger.error(message) - logger.error(exc) - logger.error(exc.response.raw_response.json()) - if die: - sys.exit(1) - - -gh = GitHub(os.environ["GITHUB_TOKEN"]) - -with log_fail("Invalid token"): - # Do the simplest request as a test - gh.rest.rate_limit.get() - - -async def fetch_and_parse_codeowners() -> dict[str, str]: - logger.debug("Fetching CODEOWNERS file...") - with log_fail("Failed to fetch CODEOWNERS file"): - content = ( - await gh.rest.repos.async_get_content( - ORG_NAME, - REPO_NAME, - "CODEOWNERS", - headers={"Accept": "application/vnd.github.raw+json"}, - ) - ).text - - logger.debug("Parsing CODEOWNERS file...") - codeowners: dict[str, str] = {} - for line in content.splitlines(): - if not line or line.lstrip().startswith("#"): - continue - - # This assumes that all entries only list one owner - # and that this owner is a team (ghostty-org/foobar) - path, owner = line.split() - path = path.lstrip("/") - owner = owner.removeprefix(f"@{ORG_NAME}/") - - if not is_localization_team(owner): - logger.debug(f"Skipping non-l11n codeowner {owner!r} for {path}") - continue - - codeowners[path] = owner - logger.debug(f"Found codeowner {owner!r} for {path}") - return codeowners - - -async def get_team_members(team_name: str) -> list[str]: - logger.debug(f"Fetching team {team_name!r}...") - with log_fail(f"Failed to fetch team {team_name!r}"): - team = (await gh.rest.teams.async_get_by_name(ORG_NAME, team_name)).parsed_data - - if team.parent and team.parent.slug == ALLOWED_PARENT_TEAM: - logger.debug(f"Fetching team {team_name!r} members...") - with log_fail(f"Failed to fetch team {team_name!r} members"): - resp = await gh.rest.teams.async_list_members_in_org(ORG_NAME, team_name) - members = [m.login for m in resp.parsed_data] - logger.debug(f"Team {team_name!r} members: {', '.join(members)}") - return members - - logger.warning(f"Team {team_name} does not have a {ALLOWED_PARENT_TEAM!r} parent") - return [] - - -async def get_changed_files(pr_number: int) -> list[str]: - logger.debug("Gathering changed files...") - with log_fail("Failed to gather changed files"): - diff_entries = ( - await gh.rest.pulls.async_list_files( - ORG_NAME, - REPO_NAME, - pr_number, - per_page=3000, - headers={"Accept": "application/vnd.github+json"}, - ) - ).parsed_data - return [d.filename for d in diff_entries] - - -async def request_review(pr_number: int, user: str, pr_author: str) -> None: - if user == pr_author: - logger.debug(f"Skipping review request for {user!r} (is PR author)") - logger.debug(f"Requesting review from {user!r}...") - with log_fail(f"Failed to request review from {user}", die=False): - await gh.rest.pulls.async_request_reviewers( - ORG_NAME, - REPO_NAME, - pr_number, - headers={"Accept": "application/vnd.github+json"}, - data={"reviewers": [user]}, - ) - - -def is_localization_team(team_name: str) -> bool: - return LOCALIZATION_TEAM_NAME_PATTERN.fullmatch(team_name) is not None - - -async def get_pr_author(pr_number: int) -> str: - logger.debug("Fetching PR author...") - with log_fail("Failed to fetch PR author"): - resp = await gh.rest.pulls.async_get(ORG_NAME, REPO_NAME, pr_number) - pr_author = resp.parsed_data.user.login - logger.debug(f"Found author: {pr_author!r}") - return pr_author - - -async def main() -> None: - logger.debug("Reading PR number...") - pr_number = int(os.environ["PR_NUMBER"]) - logger.debug(f"Starting review request process for PR #{pr_number}...") - - changed_files = await get_changed_files(pr_number) - logger.debug(f"Changed files: {', '.join(map(repr, changed_files))}") - - pr_author = await get_pr_author(pr_number) - codeowners = await fetch_and_parse_codeowners() - - found_owners = set[str]() - for file in changed_files: - logger.debug(f"Finding owner for {file!r}...") - for path, owner in codeowners.items(): - if file.startswith(path): - logger.debug(f"Found owner: {owner!r}") - break - else: - logger.debug("No owner found") - continue - found_owners.add(owner) - - member_lists = await asyncio.gather( - *(get_team_members(owner) for owner in found_owners) - ) - await asyncio.gather( - *( - request_review(pr_number, user, pr_author) - for user in chain.from_iterable(member_lists) - ) - ) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/.github/workflows/review.yml b/.github/workflows/review.yml deleted file mode 100644 index 9abe0b5e2..000000000 --- a/.github/workflows/review.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: Request Review - -on: - pull_request: - types: - - opened - - synchronize - -env: - PY_COLORS: 1 - -jobs: - review: - runs-on: namespace-profile-ghostty-xsm - steps: - - uses: actions/checkout@v4 - - - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.2.0 - with: - path: | - /nix - /zig - - - uses: cachix/install-nix-action@v30 - with: - nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@v15 - with: - name: ghostty - authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" - - - name: Request Localization Review - env: - GITHUB_TOKEN: ${{ secrets.GH_REVIEW_TOKEN }} - PR_NUMBER: ${{ github.event.pull_request.number }} - run: nix develop -c uv run .github/scripts/request_review.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index be28d50fb..e6e3a77a0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -374,6 +374,11 @@ jobs: /zig - run: sudo apt install -y udev - run: sudo systemctl start systemd-udevd + # Workaround until this is fixed: https://github.com/canonical/lxd-pkg-snap/pull/789 + - run: | + _LXD_SNAP_DEVCGROUP_CONFIG="/var/lib/snapd/cgroup/snap.lxd.device" + sudo mkdir -p /var/lib/snapd/cgroup + echo 'self-managed=true' | sudo tee "${_LXD_SNAP_DEVCGROUP_CONFIG}" - uses: snapcore/action-build@v1 with: path: dist diff --git a/CODEOWNERS b/CODEOWNERS index 0665aa407..3d8a4da3d 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -144,7 +144,7 @@ # Shell /src/shell-integration/ @ghostty-org/shell -/src/termio/shell-integration.zig @ghostty-org/shell +/src/termio/shell_integration.zig @ghostty-org/shell # Terminal /src/simd/ @ghostty-org/terminal diff --git a/flatpak/com.mitchellh.ghostty.Devel.yml b/flatpak/com.mitchellh.ghostty.Devel.yml index a939fda9a..244c3987f 100644 --- a/flatpak/com.mitchellh.ghostty.Devel.yml +++ b/flatpak/com.mitchellh.ghostty.Devel.yml @@ -1,6 +1,6 @@ app-id: com.mitchellh.ghostty.Devel runtime: org.gnome.Platform -runtime-version: "47" +runtime-version: "48" sdk: org.gnome.Sdk sdk-extensions: - org.freedesktop.Sdk.Extension.ziglang diff --git a/flatpak/com.mitchellh.ghostty.yml b/flatpak/com.mitchellh.ghostty.yml index 6f387481c..17c92633f 100644 --- a/flatpak/com.mitchellh.ghostty.yml +++ b/flatpak/com.mitchellh.ghostty.yml @@ -1,6 +1,6 @@ app-id: com.mitchellh.ghostty runtime: org.gnome.Platform -runtime-version: "47" +runtime-version: "48" sdk: org.gnome.Sdk sdk-extensions: - org.freedesktop.Sdk.Extension.ziglang 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 682099e92..a3a3185d9 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -52,6 +52,8 @@ class AppDelegate: NSObject, @IBOutlet private var menuSelectSplitLeft: NSMenuItem? @IBOutlet private var menuSelectSplitRight: NSMenuItem? @IBOutlet private var menuReturnToDefaultSize: NSMenuItem? + @IBOutlet private var menuFloatOnTop: NSMenuItem? + @IBOutlet private var menuUseAsDefault: NSMenuItem? @IBOutlet private var menuIncreaseFontSize: NSMenuItem? @IBOutlet private var menuDecreaseFontSize: NSMenuItem? @@ -175,6 +177,12 @@ class AppDelegate: NSObject, handler: localEventHandler) // Notifications + NotificationCenter.default.addObserver( + self, + selector: #selector(windowDidBecomeKey), + name: NSWindow.didBecomeKeyNotification, + object: nil + ) NotificationCenter.default.addObserver( self, selector: #selector(quickTerminalDidChangeVisibility), @@ -406,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) @@ -497,6 +506,10 @@ class AppDelegate: NSObject, return event } + @objc private func windowDidBecomeKey(_ notification: Notification) { + syncFloatOnTopMenu(notification.object as? NSWindow) + } + @objc private func quickTerminalDidChangeVisibility(_ notification: Notification) { guard let quickController = notification.object as? QuickTerminalController else { return } self.menuQuickTerminal?.state = if (quickController.visible) { .on } else { .off } @@ -899,3 +912,50 @@ 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 { + func validateMenuItem(_ item: NSMenuItem) -> Bool { + switch item.action { + case #selector(floatOnTop(_:)), + #selector(useAsDefault(_:)): + // Float on top items only active if the key window is a primary + // terminal window (not quick terminal). + return NSApp.keyWindow is TerminalWindow + + default: + return true + } + } +} diff --git a/macos/Sources/App/macOS/MainMenu.xib b/macos/Sources/App/macOS/MainMenu.xib index 8f7b16aa9..724f21355 100644 --- a/macos/Sources/App/macOS/MainMenu.xib +++ b/macos/Sources/App/macOS/MainMenu.xib @@ -25,6 +25,7 @@ + @@ -56,6 +57,7 @@ + @@ -402,6 +404,19 @@ + + + + + + + + + + + + + diff --git a/macos/Sources/Features/Terminal/TerminalWindow.swift b/macos/Sources/Features/Terminal/TerminalWindow.swift index 3209449e4..62b8dc5bf 100644 --- a/macos/Sources/Features/Terminal/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/TerminalWindow.swift @@ -1,6 +1,9 @@ import Cocoa class TerminalWindow: NSWindow { + /// This is the key in UserDefaults to use for the default `level` value. + static let defaultLevelKey: String = "TerminalDefaultLevel" + @objc dynamic var keyEquivalent: String = "" /// This is used to determine if certain elements should be drawn light or dark and should @@ -63,6 +66,8 @@ class TerminalWindow: NSWindow { if titlebarTabs { generateToolbar() } + + level = UserDefaults.standard.value(forKey: Self.defaultLevelKey) as? NSWindow.Level ?? .normal } deinit { 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/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 5985d64a0..921c32c8b 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -975,7 +975,14 @@ extension Ghostty { event: event, translationEvent: translationEvent, text: translationEvent.ghosttyCharacters, - composing: markedText.length > 0 + + // We're composing if we have preedit (the obvious case). But we're also + // composing if we don't have preedit and we had marked text before, + // because this input probably just reset the preedit state. It shouldn't + // be encoded. Example: Japanese begin composing, the press backspace. + // This should only cancel the composing state but not actually delete + // the prior input characters (prior to the composing). + composing: markedText.length > 0 || markedTextBefore ) } } diff --git a/po/ru_RU.UTF-8.po b/po/ru_RU.UTF-8.po index 2d13e6de7..9e9cf8077 100644 --- a/po/ru_RU.UTF-8.po +++ b/po/ru_RU.UTF-8.po @@ -45,7 +45,7 @@ msgid "" "One or more configuration errors were found. Please review the errors below, " "and either reload your configuration or ignore these errors." msgstr "" -"Конфигурация содержит ошибки. Проверьте их ниже, а затемлибо перезагрузите " +"Конфигурация содержит ошибки. Проверьте их ниже, а затем либо перезагрузите " "конфигурацию, либо проигнорируйте ошибки." #: src/apprt/gtk/ui/1.5/config-errors-dialog.blp:9 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 72c0d7509..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, @@ -1291,6 +1292,13 @@ pub fn run(self: *App) !void { // Setup our actions self.initActions(); + // On startup, we want to check for configuration errors right away + // so we can show our error window. We also need to setup other initial + // state. + self.syncConfigChanges(null) catch |err| { + log.warn("error handling configuration changes err={}", .{err}); + }; + while (self.running) { _ = glib.MainContext.iteration(self.ctx, 1); diff --git a/src/config/Config.zig b/src/config/Config.zig index 72f610e1e..bfaaff554 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -2280,6 +2280,18 @@ keybind: Keybinds = .{}, /// Custom CSS files to be loaded. /// +/// GTK CSS documentation can be found at the following links: +/// +/// * - An overview of GTK CSS. +/// * - A comprehensive list +/// of supported CSS properties. +/// +/// Launch Ghostty with `env GTK_DEBUG=interactive ghostty` to tweak Ghostty's +/// CSS in real time using the GTK Inspector. Errors in your CSS files would +/// also be reported in the terminal you started Ghostty from. See +/// for more +/// information about the GTK Inspector. +/// /// This configuration can be repeated multiple times to load multiple files. /// Prepend a ? character to the file path to suppress errors if the file does /// not exist. If you want to include a file that begins with a literal ? 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", diff --git a/src/terminal/dcs.zig b/src/terminal/dcs.zig index da6f3ae23..db5f95c4f 100644 --- a/src/terminal/dcs.zig +++ b/src/terminal/dcs.zig @@ -162,7 +162,12 @@ pub const Handler = struct { break :tmux .{ .tmux = .{ .exit = {} } }; }, - .xtgettcap => |list| .{ .xtgettcap = .{ .data = list } }, + .xtgettcap => |list| xtgettcap: { + for (list.items, 0..) |b, i| { + list.items[i] = std.ascii.toUpper(b); + } + break :xtgettcap .{ .xtgettcap = .{ .data = list } }; + }, .decrqss => |buffer| .{ .decrqss = switch (buffer.len) { 0 => .none, @@ -306,6 +311,21 @@ test "XTGETTCAP command" { try testing.expect(cmd.xtgettcap.next() == null); } +test "XTGETTCAP mixed case" { + const testing = std.testing; + const alloc = testing.allocator; + + var h: Handler = .{}; + defer h.deinit(); + try testing.expect(h.hook(alloc, .{ .intermediates = "+", .final = 'q' }) == null); + for ("536d756C78") |byte| _ = h.put(byte); + var cmd = h.unhook().?; + defer cmd.deinit(); + try testing.expect(cmd == .xtgettcap); + try testing.expectEqualStrings("536D756C78", cmd.xtgettcap.next().?); + try testing.expect(cmd.xtgettcap.next() == null); +} + test "XTGETTCAP command multiple keys" { const testing = std.testing; const alloc = testing.allocator; @@ -333,7 +353,7 @@ test "XTGETTCAP command invalid data" { var cmd = h.unhook().?; defer cmd.deinit(); try testing.expect(cmd == .xtgettcap); - try testing.expectEqualStrings("who", cmd.xtgettcap.next().?); + try testing.expectEqualStrings("WHO", cmd.xtgettcap.next().?); try testing.expectEqualStrings("536D756C78", cmd.xtgettcap.next().?); try testing.expect(cmd.xtgettcap.next() == null); }