diff --git a/README.md b/README.md index 2113ba4..fb69b6d 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index b58ad49..ca30b53 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -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 diff --git a/Sources/ManageWindow.swift b/Sources/ManageWindow.swift index cc7268c..4ca1c6d 100644 --- a/Sources/ManageWindow.swift +++ b/Sources/ManageWindow.swift @@ -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 {