diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceGroupHeaderRow.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceGroupHeaderRow.swift index a1817d827bf2..9fee117bc129 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceGroupHeaderRow.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceGroupHeaderRow.swift @@ -180,65 +180,69 @@ struct WorkspaceGroupHeaderRow: View, Equatable { @ViewBuilder private var contextMenu: some View { - if value.canSetGroupPinned, let setGroupPinned = actions.setGroupPinned { - Button { - setGroupPinned(group.id, !group.isPinned) - } label: { - if group.isPinned { - Label(L10n.string("mobile.workspaceGroup.unpin", defaultValue: "Unpin Group"), systemImage: "pin.slash") - } else { - Label(L10n.string("mobile.workspaceGroup.pin", defaultValue: "Pin Group"), systemImage: "pin") + if value.canSetGroupPinned || value.canRenameGroup { + Section { + if value.canSetGroupPinned, let setGroupPinned = actions.setGroupPinned { + Button { + setGroupPinned(group.id, !group.isPinned) + } label: { + if group.isPinned { + Label(L10n.string("mobile.workspaceGroup.unpin", defaultValue: "Unpin Group"), systemImage: "pin.slash") + } else { + Label(L10n.string("mobile.workspaceGroup.pin", defaultValue: "Pin Group"), systemImage: "pin") + } + } + .accessibilityIdentifier("MobileWorkspaceGroupPinButton-\(group.id.rawValue)") + } + if value.canRenameGroup { + Button { + isRenaming = true + } label: { + Label(L10n.string("mobile.workspaceGroup.rename.action", defaultValue: "Rename Group"), systemImage: "pencil") + } + .accessibilityIdentifier("MobileWorkspaceGroupRenameButton-\(group.id.rawValue)") } } - .accessibilityIdentifier("MobileWorkspaceGroupPinButton-\(group.id.rawValue)") - } - if value.canRenameGroup { - Button { - isRenaming = true - } label: { - Label(L10n.string("mobile.workspaceGroup.rename.action", defaultValue: "Rename Group"), systemImage: "pencil") - } - .accessibilityIdentifier("MobileWorkspaceGroupRenameButton-\(group.id.rawValue)") - } - if value.canRenameGroup || value.canSetGroupPinned { - Divider() } if value.canCreateWorkspaceInGroup, let createWorkspaceInGroup = actions.createWorkspaceInGroup { - Button { - createWorkspaceInGroup(group.id) - } label: { - Label( - L10n.string("mobile.workspaceGroup.newWorkspace", defaultValue: "New Workspace in Group"), - systemImage: "plus" - ) + Section { + Button { + createWorkspaceInGroup(group.id) + } label: { + Label( + L10n.string("mobile.workspaceGroup.newWorkspace", defaultValue: "New Workspace in Group"), + systemImage: "plus" + ) + } + .accessibilityIdentifier("MobileWorkspaceGroupNewWorkspace-\(group.id.rawValue)") } - .accessibilityIdentifier("MobileWorkspaceGroupNewWorkspace-\(group.id.rawValue)") } if value.canUngroupWorkspaceGroup || value.canDeleteWorkspaceGroup { - Divider() - } - if value.canUngroupWorkspaceGroup { - Button(role: .destructive) { - pendingDestructiveAction = .ungroup - } label: { - Label( - L10n.string("mobile.workspaceGroup.ungroup", defaultValue: "Ungroup (Keep Workspaces)"), - systemImage: "rectangle.3.group" - ) - } - .accessibilityIdentifier("MobileWorkspaceGroupUngroupButton-\(group.id.rawValue)") - } - if value.canDeleteWorkspaceGroup { - Button(role: .destructive) { - pendingDestructiveAction = .delete - } label: { - Label( - L10n.string("mobile.workspaceGroup.delete", defaultValue: "Delete Group (Close Workspaces)"), - systemImage: "trash" - ) + Section { + if value.canUngroupWorkspaceGroup { + Button(role: .destructive) { + pendingDestructiveAction = .ungroup + } label: { + Label( + L10n.string("mobile.workspaceGroup.ungroup", defaultValue: "Ungroup (Keep Workspaces)"), + systemImage: "rectangle.3.group" + ) + } + .accessibilityIdentifier("MobileWorkspaceGroupUngroupButton-\(group.id.rawValue)") + } + if value.canDeleteWorkspaceGroup { + Button(role: .destructive) { + pendingDestructiveAction = .delete + } label: { + Label( + L10n.string("mobile.workspaceGroup.delete", defaultValue: "Delete Group (Close Workspaces)"), + systemImage: "trash" + ) + } + .accessibilityIdentifier("MobileWorkspaceGroupDeleteButton-\(group.id.rawValue)") + } } - .accessibilityIdentifier("MobileWorkspaceGroupDeleteButton-\(group.id.rawValue)") } } diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceBridge.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceBridge.swift index 104108eaa2b6..fcbde625db45 100644 --- a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceBridge.swift +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceBridge.swift @@ -64,6 +64,23 @@ final class GhosttySurfaceBridge: @unchecked Sendable { } } + static let ioWriteCallback: @convention(c) ( + UnsafeMutableRawPointer?, + UnsafePointer?, + UInt + ) -> Void = { userdata, buf, len in + guard let buf, len > 0 else { return } + let data = Data(bytes: buf, count: Int(len)) + GhosttySurfaceBridge.fromOpaque(userdata)?.handleWrite(data) + } + + static let renderPresentedCallback: @convention(c) ( + UnsafeMutableRawPointer?, + UInt64 + ) -> Void = { userdata, token in + GhosttySurfaceBridge.fromOpaque(userdata)?.handleRenderPresented(token: token) + } + static func fromOpaque(_ userdata: UnsafeMutableRawPointer?) -> GhosttySurfaceBridge? { guard let userdata else { return nil } return Unmanaged.fromOpaque(userdata).takeUnretainedValue() diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift index b61e8f787715..1216b1ce1693 100644 --- a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift @@ -3648,14 +3648,7 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { surfaceConfig.font_size = liveFontSize surfaceConfig.context = GHOSTTY_SURFACE_CONTEXT_WINDOW surfaceConfig.io_mode = GHOSTTY_SURFACE_IO_MANUAL - surfaceConfig.io_write_cb = { userdata, buf, len in - guard let userdata, let buf, len > 0 else { return } - let data = Data(bytes: buf, count: Int(len)) - let bridge = Unmanaged.fromOpaque(userdata).takeUnretainedValue() - DispatchQueue.main.async { - bridge.surfaceView?.handleOutboundBytes(data) - } - } + surfaceConfig.io_write_cb = GhosttySurfaceBridge.ioWriteCallback surfaceConfig.io_write_userdata = bridgePointer guard let createdSurface = ghostty_surface_new(app, &surfaceConfig) else { retainedBridge.release() @@ -3663,9 +3656,7 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { } guard ghostty_surface_set_render_presented_callback( createdSurface, - { userdata, token in - GhosttySurfaceBridge.fromOpaque(userdata)?.handleRenderPresented(token: token) - }, + GhosttySurfaceBridge.renderPresentedCallback, bridgePointer ) else { ghostty_surface_free(createdSurface) diff --git a/Packages/iOS/CmuxMobileTerminal/Tests/CmuxMobileTerminalTests/GhosttySurfaceCallbackTests.swift b/Packages/iOS/CmuxMobileTerminal/Tests/CmuxMobileTerminalTests/GhosttySurfaceCallbackTests.swift new file mode 100644 index 000000000000..8c0b51c03478 --- /dev/null +++ b/Packages/iOS/CmuxMobileTerminal/Tests/CmuxMobileTerminalTests/GhosttySurfaceCallbackTests.swift @@ -0,0 +1,34 @@ +#if canImport(UIKit) +import Foundation +import Testing +@testable import CmuxMobileTerminal + +@Suite("Ghostty surface callbacks") +struct GhosttySurfaceCallbackTests { + @Test("libghostty surface callbacks can run off the main thread") + func ghosttySurfaceCallbacksRunOffMainThread() async { + let bridge = GhosttySurfaceBridge() + let userdata = Unmanaged.passRetained(bridge).toOpaque() + let userdataAddress = UInt(bitPattern: userdata) + defer { GhosttySurfaceBridge.releaseRetainedOpaque(userdata) } + + let ranOnMainThread = await withCheckedContinuation { continuation in + DispatchQueue(label: "dev.cmux.tests.ghostty-surface-callback").async { + let callbackUserdata = UnsafeMutableRawPointer(bitPattern: userdataAddress) + let bytes = Array("focus".utf8CString) + bytes.withUnsafeBufferPointer { buffer in + GhosttySurfaceBridge.ioWriteCallback( + callbackUserdata, + buffer.baseAddress, + UInt(max(buffer.count - 1, 0)) + ) + } + GhosttySurfaceBridge.renderPresentedCallback(callbackUserdata, 42) + continuation.resume(returning: Thread.isMainThread) + } + } + + #expect(!ranOnMainThread) + } +} +#endif