Let the menu's click and shift-click actions be swapped

Clicking a profile switched to it exclusively and shift-click opened it
alongside, with no way round for anyone who opens alongside far more often
than they switch. Settings now carries the choice, storing only the plain
click's action — the other gesture gets whatever is left, so the two can't
both end up meaning the same thing, and no migration is needed for anyone
who never touches it.

Switching also quits the other profiles again when the profile clicked is
already up, which e7777d5 had carved out on the reasoning that a click
reaching for a second open window is someone moving between windows rather
than asking for one to go. That guess is what this setting now settles:
with each action bound to a gesture of its own, choosing the switch gesture
is the instruction, and second-guessing it left no way to ask for an
exclusive switch at all once the target was running.

The menu spells out both gestures rather than only the modified one, since
which of them quits things is no longer fixed. Each profile's tooltip
describes what a click will actually do to it as things stand — it promises
to quit the neighbours only when there are neighbours to quit.
This commit is contained in:
Claude Opus 5
2026-08-14 23:14:49 +06:00
committed by bdeshi
parent 97b131070e
commit ffbd1536b6
3 changed files with 143 additions and 36 deletions
+11 -5
View File
@@ -43,11 +43,15 @@ one is up, dots alone when there are more (hover for the names).
Open the menu and:
- **Click a profile that isn't running** to switch to it. Every other running
profile is quit first, so you end up with just that one.
- **Click a profile that is already running** to bring its window forward,
leaving everything else where it is.
- **Shift-click a profile** to open it *alongside* whatever is already running.
- **Click a profile** to switch to it. Every other running profile is quit
first, so you end up with just that one — including when the profile you
clicked is already up, in which case it's the neighbours that go.
- **Shift-click a profile** to open it *alongside* whatever is already running,
or to bring its window forward if it's already up, leaving everything else
where it is.
If you open alongside more often than you switch, **Settings → Click a profile
to** swaps the two around.
A `⇆` marks the profile you last switched to. **Close Profile** quits one
profile; **Quit Claude** quits all of them and leaves shannoncoat running.
@@ -89,6 +93,8 @@ it's running, it's quit first.
## Settings
- **Launch at Login** — start shannoncoat automatically.
- **Click a profile to** — whether plain click switches and Shift-click opens
alongside, or the reverse.
- **Window tag** — name chip or coloured dot.
- **Automatically check for updates** — once a day at most.
+89 -23
View File
@@ -4,6 +4,43 @@
import AppKit
import ApplicationServices
// What clicking a profile in the menu does. The two actions are always both
// reachable whichever one isn't on the plain click is on Shift-click so
// this is a swap, not an on/off.
enum MenuClickAction: String {
case switchTo
case openAlongside
// Names the action generically, for the menu's two hint lines the
// per-profile tooltips say what it does to that profile as things
// actually stand (see AppDelegate.outcome).
var phrase: String {
switch self {
case .switchTo: return "switch (quits the others)"
case .openAlongside: return "open alongside"
}
}
}
// One setting for the whole app, stored as the *plain* click's action; the
// Shift-click action is derived, which keeps the two from ever being set to
// the same thing.
enum MenuClickBehavior {
private static let plainClickKey = "MenuPlainClickAction"
static var plainClick: MenuClickAction {
get {
UserDefaults.standard.string(forKey: plainClickKey)
.flatMap(MenuClickAction.init(rawValue:)) ?? .switchTo
}
set { UserDefaults.standard.set(newValue.rawValue, forKey: plainClickKey) }
}
static var shiftClick: MenuClickAction {
plainClick == .switchTo ? .openAlongside : .switchTo
}
}
final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, LiveStateDelegate {
let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
let menu = NSMenu()
@@ -239,15 +276,29 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, LiveSt
menu.addItem(header)
menu.addItem(.separator())
let hint = NSMenuItem(title: "\u{21e7}-click to open alongside", action: nil, keyEquivalent: "")
hint.isEnabled = false
menu.addItem(hint)
// Read once per rebuild rather than per item. The menu is rebuilt
// from scratch on every open, so changing the setting is reflected
// the next time the menu is shown without anything having to tell
// the delegate about it.
let plainClick = MenuClickBehavior.plainClick
let shiftClick = MenuClickBehavior.shiftClick
// Both gestures are spelled out, not just the modified one: which is
// which is now a setting, so a single line about Shift would leave the
// plain click to be assumed and it's the plain click that quits
// things when it's the one bound to switching.
for (gesture, action) in [("Click", plainClick), ("\u{21e7}-click", shiftClick)] {
let hint = NSMenuItem(title: "\(gesture) to \(action.phrase)", action: nil, keyEquivalent: "")
hint.isEnabled = false
menu.addItem(hint)
}
menu.addItem(.separator())
for info in profiles {
// Click = exclusive switch (quits every other running profile).
// Shift-click = open alongside instead, without disturbing
// anything else that's running.
// One click switches (quitting every other running profile), the
// other opens alongside without disturbing what's running which
// is on the plain click and which is on Shift is the user's
// choice, see MenuClickBehavior.
let title = info.profile.name == liveState.lastActiveName
? "\(info.profile.name) \u{21c6}" : info.profile.name
let item = NSMenuItem(title: title, action: #selector(pick(_:)), keyEquivalent: "")
@@ -255,7 +306,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, LiveSt
item.representedObject = info.profile.name
item.image = ProfileColor.dotImage(for: info.profile.name, dimmed: !info.running)
item.state = info.running ? .on : .off
item.toolTip = info.running ? "Running — click to focus" : "Click to switch • Shift-click to open alongside"
item.toolTip = "Click to \(outcome(of: plainClick, for: info))"
+ "\u{21e7}-click to \(outcome(of: shiftClick, for: info))"
menu.addItem(item)
}
@@ -299,6 +351,22 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, LiveSt
menu.addItem(quitSelfItem)
}
// What a gesture will actually do to THIS profile as things stand the
// same action reads differently depending on what's up. Switching only
// quits neighbours when there are neighbours to quit, and opening
// alongside is only "alongside" something when something else is running.
private func outcome(of action: MenuClickAction, for info: ProfileInfo) -> String {
let others = currentInfos.filter { $0.running && $0.profile.name != info.profile.name }.count
let reach = info.running ? "focus it" : "start it"
switch action {
case .openAlongside:
return others == 0 ? reach : "\(reach), leaving the others running"
case .switchTo:
guard others > 0 else { return reach }
return "\(reach), quitting the other \(others == 1 ? "profile" : "\(others) profiles")"
}
}
// Shown once at launch when nothing is running. A checklist rather
// than a single pick: unlike the menu's click/Shift-click (one
// profile, exclusive vs. alongside), there's nothing running yet to be
@@ -342,10 +410,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, LiveSt
let profile = currentInfos.first(where: { $0.profile.name == name })?.profile
else { return }
liveState.markActive(name)
if NSApp.currentEvent?.modifierFlags.contains(.shift) == true {
openProfile(profile)
} else {
switchTo(profile)
let shiftHeld = NSApp.currentEvent?.modifierFlags.contains(.shift) == true
switch shiftHeld ? MenuClickBehavior.shiftClick : MenuClickBehavior.plainClick {
case .openAlongside: openProfile(profile)
case .switchTo: switchTo(profile)
}
}
@@ -360,19 +428,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, LiveSt
}
}
// Switching means "make this the one that's running" so it only has to
// quit anything when the profile isn't up yet. Clicking one that's
// already open is a request to look at it, not to tear down the window
// beside it: the menu shows every running profile at once, so the click
// that reaches for the second one is almost always someone moving
// between two open windows rather than asking for one of them to go.
// Quitting the neighbour there costs a relaunch and whatever was on
// screen, to carry out an instruction nobody gave.
// Switching means "make this the one that's running", so it quits the
// others whether or not this profile is already up.
//
// It used to bail out early for an already-running profile, on the
// reasoning that a click reaching for a second open window is someone
// moving between windows rather than asking for one to go. That guess is
// exactly what MenuClickBehavior now settles: both actions have a gesture
// of their own, so choosing the switch gesture *is* the instruction, and
// second-guessing it left no way to ask for an exclusive switch at all
// once the target was up.
private func switchTo(_ profile: ResolvedProfile) {
guard !currentInfos.contains(where: { $0.profile.name == profile.name && $0.running }) else {
openProfile(profile)
return
}
let others = currentInfos.filter { $0.running && $0.profile.name != profile.name }
guard !others.isEmpty else { openProfile(profile); return }
var remaining = others.count
+43 -8
View File
@@ -512,6 +512,9 @@ private final class SettingsViewController: NSViewController {
private let tagStyleLabel = NSTextField(labelWithString: "Window tag:")
private let tagStylePopUp = NSPopUpButton(frame: .zero, pullsDown: false)
private var tagStyleRow: NSStackView!
private let clickActionLabel = NSTextField(labelWithString: "Click a profile to:")
private let clickActionPopUp = NSPopUpButton(frame: .zero, pullsDown: false)
private var clickActionRow: NSStackView!
private var stack: NSStackView!
var onOverlayStyleChanged: (() -> Void)?
@@ -523,6 +526,14 @@ private final class SettingsViewController: NSViewController {
("Colored dot", .dot),
]
// Same index-to-value arrangement. Each title spells out the Shift-click
// half too: the two actions always swap together, so showing only the
// plain click's half would leave the other looking unset.
private static let clickActions: [(title: String, action: MenuClickAction)] = [
("Switch to it (\u{21e7}: open alongside)", .switchTo),
("Open it alongside (\u{21e7}: switch to it)", .openAlongside),
]
override func loadView() {
view = NSView(frame: NSRect(x: 0, y: 0, width: 420, height: 150))
@@ -535,6 +546,16 @@ private final class SettingsViewController: NSViewController {
tagStyleRow.orientation = .horizontal
tagStyleRow.spacing = 8
clickActionPopUp.addItems(withTitles: Self.clickActions.map(\.title))
clickActionPopUp.selectItem(
at: Self.clickActions.firstIndex { $0.action == MenuClickBehavior.plainClick } ?? 0)
clickActionPopUp.target = self
clickActionPopUp.action = #selector(changeClickAction)
clickActionRow = NSStackView(views: [clickActionLabel, clickActionPopUp])
clickActionRow.orientation = .horizontal
clickActionRow.spacing = 8
launchAtLoginCheckbox.target = self
launchAtLoginCheckbox.action = #selector(toggleLaunchAtLogin)
launchAtLoginCheckbox.state = LaunchAtLogin.isEnabled ? .on : .off
@@ -547,7 +568,9 @@ private final class SettingsViewController: NSViewController {
checkNowButton.action = #selector(checkNow)
checkNowButton.bezelStyle = .rounded
stack = NSStackView(views: [launchAtLoginCheckbox, tagStyleRow, autoUpdateCheckbox, checkNowButton])
stack = NSStackView(views: [
launchAtLoginCheckbox, clickActionRow, tagStyleRow, autoUpdateCheckbox, checkNowButton,
])
stack.orientation = .vertical
stack.alignment = .leading
stack.spacing = 14
@@ -578,14 +601,18 @@ private final class SettingsViewController: NSViewController {
// is what AutoLayout itself uses to size an unconstrained control
// sidesteps whatever the stack's own aggregation is getting wrong.
private func updatePreferredSize() {
// Same reasoning for the tag-style row: summed from its two
// controls plus the stack spacing rather than trusting the row's
// Same reasoning for the label+popup rows: summed from their two
// controls plus the row spacing rather than trusting the row's
// own fittingSize.
let rowWidth = tagStyleLabel.intrinsicContentSize.width
+ tagStylePopUp.intrinsicContentSize.width + tagStyleRow.spacing
let widest = [launchAtLoginCheckbox, autoUpdateCheckbox, checkNowButton]
.map(\.intrinsicContentSize.width)
.max().map { max($0, rowWidth) } ?? rowWidth
let rowWidths = [
tagStyleLabel.intrinsicContentSize.width
+ tagStylePopUp.intrinsicContentSize.width + tagStyleRow.spacing,
clickActionLabel.intrinsicContentSize.width
+ clickActionPopUp.intrinsicContentSize.width + clickActionRow.spacing,
]
let widest = ([launchAtLoginCheckbox, autoUpdateCheckbox, checkNowButton]
.map(\.intrinsicContentSize.width) + rowWidths)
.max() ?? 0
preferredContentSize = NSSize(width: widest + 40, height: stack.fittingSize.height)
}
@@ -596,6 +623,14 @@ private final class SettingsViewController: NSViewController {
onOverlayStyleChanged?()
}
// Nothing to notify: the menu bar rebuilds its menu on every open and
// reads the setting then.
@objc private func changeClickAction() {
let index = clickActionPopUp.indexOfSelectedItem
guard Self.clickActions.indices.contains(index) else { return }
MenuClickBehavior.plainClick = Self.clickActions[index].action
}
@objc private func toggleLaunchAtLogin() {
do {
if launchAtLoginCheckbox.state == .on {