diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 23506926..deeb1546 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -97,6 +97,7 @@ F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */; }; F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */; }; F9000000A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */; }; + F1C1AA21B7E84D10A1C10001 /* InactivePaneFirstClickFocusTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1C1AA20B7E84D10A1C10001 /* InactivePaneFirstClickFocusTests.swift */; }; FA000000A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA000001A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift */; }; A5008381 /* BrowserFindJavaScriptTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5008380 /* BrowserFindJavaScriptTests.swift */; }; A5008383 /* CommandPaletteSearchEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5008382 /* CommandPaletteSearchEngineTests.swift */; }; @@ -262,6 +263,7 @@ F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceContentViewVisibilityTests.swift; sourceTree = ""; }; F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketControlPasswordStoreTests.swift; sourceTree = ""; }; F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyEnsureFocusWindowActivationTests.swift; sourceTree = ""; }; + F1C1AA20B7E84D10A1C10001 /* InactivePaneFirstClickFocusTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InactivePaneFirstClickFocusTests.swift; sourceTree = ""; }; FA000001A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceStressProfileTests.swift; sourceTree = ""; }; A5008380 /* BrowserFindJavaScriptTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserFindJavaScriptTests.swift; sourceTree = ""; }; A5008382 /* CommandPaletteSearchEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteSearchEngineTests.swift; sourceTree = ""; }; @@ -516,6 +518,7 @@ F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */, F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */, F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */, + F1C1AA20B7E84D10A1C10001 /* InactivePaneFirstClickFocusTests.swift */, FA000001A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift */, A5008380 /* BrowserFindJavaScriptTests.swift */, A5008382 /* CommandPaletteSearchEngineTests.swift */, @@ -775,6 +778,7 @@ F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */, F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */, F9000000A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift in Sources */, + F1C1AA21B7E84D10A1C10001 /* InactivePaneFirstClickFocusTests.swift in Sources */, FA000000A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift in Sources */, A5008381 /* BrowserFindJavaScriptTests.swift in Sources */, A5008383 /* CommandPaletteSearchEngineTests.swift in Sources */, diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 94e2a8db..157ff49a 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -43335,6 +43335,57 @@ } } }, + "settings.app.paneFirstClickFocus": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Focus Pane on First Click" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "最初のクリックでペインにフォーカス" + } + } + } + }, + "settings.app.paneFirstClickFocus.subtitleOff": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "When cmux is inactive, the first click only activates the window. Click again to focus the pane." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmuxが非アクティブのとき、最初のクリックはウインドウをアクティブにするだけです。ペインにフォーカスするにはもう一度クリックします。" + } + } + } + }, + "settings.app.paneFirstClickFocus.subtitleOn": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "When cmux is inactive, clicking a pane activates the window and focuses that pane in one click." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmuxが非アクティブのとき、ペインをクリックすると1回でウインドウをアクティブにしてそのペインへフォーカスします。" + } + } + } + }, "settings.app.openSidebarPRLinks": { "extractionState": "manual", "localizations": { diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index c136afa4..75b07df8 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -4639,6 +4639,10 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { _ = inputContext.perform(updateSelectionSelector) } + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { + PaneFirstClickFocusSettings.isEnabled() + } + override var acceptsFirstResponder: Bool { true } override func becomeFirstResponder() -> Bool { diff --git a/Sources/Panels/CmuxWebView.swift b/Sources/Panels/CmuxWebView.swift index 8990e685..dae92f42 100644 --- a/Sources/Panels/CmuxWebView.swift +++ b/Sources/Panels/CmuxWebView.swift @@ -67,6 +67,10 @@ final class CmuxWebView: WKWebView { } var debugPointerFocusAllowanceDepth: Int { pointerFocusAllowanceDepth } + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { + PaneFirstClickFocusSettings.isEnabled() + } + override func becomeFirstResponder() -> Bool { guard allowsFirstResponderAcquisitionEffective else { #if DEBUG diff --git a/Sources/Panels/MarkdownPanelView.swift b/Sources/Panels/MarkdownPanelView.swift index dc8d7c6c..75367ead 100644 --- a/Sources/Panels/MarkdownPanelView.swift +++ b/Sources/Panels/MarkdownPanelView.swift @@ -307,6 +307,7 @@ private struct MarkdownPointerObserver: NSViewRepresentable { final class MarkdownPanelPointerObserverView: NSView { var onPointerDown: (() -> Void)? private var eventMonitor: Any? + private weak var forwardedMouseTarget: NSView? override var mouseDownCanMoveWindow: Bool { false } @@ -326,7 +327,29 @@ final class MarkdownPanelPointerObserverView: NSView { } override func hitTest(_ point: NSPoint) -> NSView? { - nil + guard PaneFirstClickFocusSettings.isEnabled(), + window?.isKeyWindow != true, + bounds.contains(point) else { return nil } + return self + } + + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { + PaneFirstClickFocusSettings.isEnabled() + } + + override func mouseDown(with event: NSEvent) { + onPointerDown?() + forwardedMouseTarget = forwardedTarget(for: event) + forwardedMouseTarget?.mouseDown(with: event) + } + + override func mouseDragged(with event: NSEvent) { + forwardedMouseTarget?.mouseDragged(with: event) + } + + override func mouseUp(with event: NSEvent) { + forwardedMouseTarget?.mouseUp(with: event) + forwardedMouseTarget = nil } func shouldHandle(_ event: NSEvent) -> Bool { @@ -334,6 +357,9 @@ final class MarkdownPanelPointerObserverView: NSView { let window, event.window === window, !isHiddenOrHasHiddenAncestor else { return false } + if PaneFirstClickFocusSettings.isEnabled(), window.isKeyWindow != true { + return false + } let point = convert(event.locationInWindow, from: nil) return bounds.contains(point) } @@ -352,4 +378,24 @@ final class MarkdownPanelPointerObserverView: NSView { self?.handleEventIfNeeded(event) ?? event } } + + private func forwardedTarget(for event: NSEvent) -> NSView? { + guard let window else { +#if DEBUG + NSLog("MarkdownPanelPointerObserverView.forwardedTarget skipped, window=0 contentView=0") +#endif + return nil + } + guard let contentView = window.contentView else { +#if DEBUG + NSLog("MarkdownPanelPointerObserverView.forwardedTarget skipped, window=1 contentView=0") +#endif + return nil + } + isHidden = true + defer { isHidden = false } + let point = contentView.convert(event.locationInWindow, from: nil) + let target = contentView.hitTest(point) + return target === self ? nil : target + } } diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index ad61dc8a..debc6697 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -87,6 +87,15 @@ enum WorkspaceButtonFadeSettings { } } +enum PaneFirstClickFocusSettings { + static let enabledKey = "paneFirstClickFocus.enabled" + static let defaultEnabled = false + + static func isEnabled(defaults: UserDefaults = .standard) -> Bool { + defaults.object(forKey: enabledKey) as? Bool ?? defaultEnabled + } +} + enum UITestLaunchManifest { static let argumentName = "-cmuxUITestLaunchManifest" @@ -3809,6 +3818,8 @@ struct SettingsView: View { @AppStorage(WorkspacePlacementSettings.placementKey) private var newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue @AppStorage(LastSurfaceCloseShortcutSettings.key) private var closeWorkspaceOnLastSurfaceShortcut = LastSurfaceCloseShortcutSettings.defaultValue + @AppStorage(PaneFirstClickFocusSettings.enabledKey) + private var paneFirstClickFocusEnabled = PaneFirstClickFocusSettings.defaultEnabled @AppStorage(WorkspaceAutoReorderSettings.key) private var workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue @AppStorage(SidebarWorkspaceDetailSettings.hideAllDetailsKey) private var sidebarHideAllDetails = SidebarWorkspaceDetailSettings.defaultHideAllDetails @@ -3902,6 +3913,19 @@ struct SettingsView: View { ) } + private var paneFirstClickFocusSubtitle: String { + if paneFirstClickFocusEnabled { + return String( + localized: "settings.app.paneFirstClickFocus.subtitleOn", + defaultValue: "When cmux is inactive, clicking a pane activates the window and focuses that pane in one click." + ) + } + return String( + localized: "settings.app.paneFirstClickFocus.subtitleOff", + defaultValue: "When cmux is inactive, the first click only activates the window. Click again to focus the pane." + ) + } + private var selectedSidebarActiveTabIndicatorStyle: SidebarActiveTabIndicatorStyle { SidebarActiveTabIndicatorSettings.resolvedStyle(rawValue: sidebarActiveTabIndicatorStyle) } @@ -4371,6 +4395,20 @@ struct SettingsView: View { SettingsCardDivider() + SettingsCardRow( + String(localized: "settings.app.paneFirstClickFocus", defaultValue: "Focus Pane on First Click"), + subtitle: paneFirstClickFocusSubtitle + ) { + Toggle("", isOn: $paneFirstClickFocusEnabled) + .labelsHidden() + .controlSize(.small) + .accessibilityLabel( + String(localized: "settings.app.paneFirstClickFocus", defaultValue: "Focus Pane on First Click") + ) + } + + SettingsCardDivider() + SettingsCardRow( String(localized: "settings.app.reorderOnNotification", defaultValue: "Reorder on Notification"), subtitle: String(localized: "settings.app.reorderOnNotification.subtitle", defaultValue: "Move workspaces to the top when they receive a notification. Disable for stable shortcut positions.") @@ -5530,6 +5568,7 @@ struct SettingsView: View { defaults.removeObject(forKey: WorkspaceButtonFadeSettings.legacyTitlebarControlsVisibilityModeKey) defaults.removeObject(forKey: WorkspaceButtonFadeSettings.legacyPaneTabBarControlsVisibilityModeKey) closeWorkspaceOnLastSurfaceShortcut = LastSurfaceCloseShortcutSettings.defaultValue + paneFirstClickFocusEnabled = PaneFirstClickFocusSettings.defaultEnabled workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue sidebarHideAllDetails = SidebarWorkspaceDetailSettings.defaultHideAllDetails sidebarShowNotificationMessage = SidebarWorkspaceDetailSettings.defaultShowNotificationMessage diff --git a/cmuxTests/InactivePaneFirstClickFocusTests.swift b/cmuxTests/InactivePaneFirstClickFocusTests.swift new file mode 100644 index 00000000..17bdc020 --- /dev/null +++ b/cmuxTests/InactivePaneFirstClickFocusTests.swift @@ -0,0 +1,72 @@ +import XCTest +import AppKit +import WebKit + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +final class InactivePaneFirstClickFocusTests: XCTestCase { + private let settingsKey = "paneFirstClickFocus.enabled" + + override func setUp() { + super.setUp() + UserDefaults.standard.removeObject(forKey: settingsKey) + } + + override func tearDown() { + UserDefaults.standard.removeObject(forKey: settingsKey) + super.tearDown() + } + + func testTerminalViewAcceptsFirstMouseWhenSettingEnabled() { + UserDefaults.standard.set(true, forKey: settingsKey) + + let view = GhosttyNSView(frame: .zero) + + XCTAssertTrue(view.acceptsFirstMouse(for: nil)) + } + + func testTerminalViewRejectsFirstMouseWhenSettingDisabled() { + UserDefaults.standard.set(false, forKey: settingsKey) + + let view = GhosttyNSView(frame: .zero) + + XCTAssertFalse(view.acceptsFirstMouse(for: nil)) + } + + func testBrowserViewAcceptsFirstMouseWhenSettingEnabled() { + UserDefaults.standard.set(true, forKey: settingsKey) + + let view = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + + XCTAssertTrue(view.acceptsFirstMouse(for: nil)) + } + + func testBrowserViewRejectsFirstMouseWhenSettingDisabled() { + UserDefaults.standard.set(false, forKey: settingsKey) + + let view = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + + XCTAssertFalse(view.acceptsFirstMouse(for: nil)) + } + + func testMarkdownPointerObserverAcceptsFirstMouseWhenSettingEnabled() { + UserDefaults.standard.set(true, forKey: settingsKey) + + let view = MarkdownPanelPointerObserverView(frame: .zero) + + XCTAssertTrue(view.acceptsFirstMouse(for: nil)) + } + + func testMarkdownPointerObserverRejectsFirstMouseWhenSettingDisabled() { + UserDefaults.standard.set(false, forKey: settingsKey) + + let view = MarkdownPanelPointerObserverView(frame: .zero) + + XCTAssertFalse(view.acceptsFirstMouse(for: nil)) + } +}