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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user