Add optional single-click focus for inactive panes (#1796)

* Add failing first-click pane focus tests

* Enable optional first-click focus for inactive panes

* Address PR review follow-ups

---------

Co-authored-by: Lawrence Chen <lawrencecchen@users.noreply.github.com>
This commit is contained in:
Lawrence Chen 2026-03-19 01:01:10 -07:00 committed by GitHub
parent 5cab7c4a7b
commit 7cbac356fc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 221 additions and 1 deletions

View file

@ -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 = "<group>"; };
F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketControlPasswordStoreTests.swift; sourceTree = "<group>"; };
F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyEnsureFocusWindowActivationTests.swift; sourceTree = "<group>"; };
F1C1AA20B7E84D10A1C10001 /* InactivePaneFirstClickFocusTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InactivePaneFirstClickFocusTests.swift; sourceTree = "<group>"; };
FA000001A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceStressProfileTests.swift; sourceTree = "<group>"; };
A5008380 /* BrowserFindJavaScriptTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserFindJavaScriptTests.swift; sourceTree = "<group>"; };
A5008382 /* CommandPaletteSearchEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteSearchEngineTests.swift; sourceTree = "<group>"; };
@ -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 */,

View file

@ -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": {

View file

@ -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 {

View file

@ -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

View file

@ -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
}
}

View file

@ -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

View file

@ -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))
}
}