Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions apps/menubar/Codogotchi.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@
588624B3B7B400E50C961066 /* HookRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93F078A9D14984485EB28AC9 /* HookRowView.swift */; };
58A715CD06913CC033DA019A /* FloatingPetWindowPool.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5CF055F2A0C9DD84F4F026A /* FloatingPetWindowPool.swift */; };
595CC685C452AE0EA1EB9E2F /* PerSessionReaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBC8A9F0A42DAF1E086D27A /* PerSessionReaderTests.swift */; };
59E6748B45FAB395638DCE99 /* FloatingPetPromptDismissal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92F7C601B6A2F543E0A4A246 /* FloatingPetPromptDismissal.swift */; };
5B8D08B5B584C695EB75E553 /* SessionLabelStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 970D0D22D956516B6FAF0632 /* SessionLabelStoreTests.swift */; };
5BE414B5F01BD917B60E917C /* SessionLabelStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A99294D27E5D1862559FCA2 /* SessionLabelStore.swift */; };
5ED2ED3F249FA9CB7F314F94 /* FloatingInteractionPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45AA3E424EA09D77F47D6C1F /* FloatingInteractionPolicy.swift */; };
Expand All @@ -87,6 +88,7 @@
686F220636F60BF638F4588B /* RPGHUDViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B45875058BDB67738967BE7A /* RPGHUDViewModel.swift */; };
68A45AC229C671EE01A9DEAC /* PerPlatformSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41B41CAA48264D60619035FE /* PerPlatformSnapshot.swift */; };
692DB55B96C40097202445F2 /* PlatformAttribution.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44643FE5868DCD56DDF5F1E2 /* PlatformAttribution.swift */; };
69FAC7752922A70F4943791A /* FloatingPetPromptBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9C3594285193B88B858A1C8 /* FloatingPetPromptBuilder.swift */; };
6A6E965676C08DCF5EA44900 /* PromptTimerChipView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1D8D2C351C51A43AE40A1A6 /* PromptTimerChipView.swift */; };
6A78A39CED185B02C213FE62 /* MenuItemsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E06FB02C825E53E28CCC8205 /* MenuItemsTests.swift */; };
6BE7F6EA53A623C22C0B98E5 /* RPGHUDIconTileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6858ADF75A07FF4C263F283 /* RPGHUDIconTileView.swift */; };
Expand Down Expand Up @@ -309,6 +311,7 @@
8CF4712922D6C0BA86E1D1A4 /* FloatingInteractionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloatingInteractionTests.swift; sourceTree = "<group>"; };
8DBC88DE074DC11652453ADE /* PetTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PetTabView.swift; sourceTree = "<group>"; };
8E2899DD8851B86633D4BB28 /* PetThumbnail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PetThumbnail.swift; sourceTree = "<group>"; };
92F7C601B6A2F543E0A4A246 /* FloatingPetPromptDismissal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloatingPetPromptDismissal.swift; sourceTree = "<group>"; };
9310785E0303F2C6627ADAE4 /* ConflictBubbleRateLimiter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConflictBubbleRateLimiter.swift; sourceTree = "<group>"; };
933225DDA49AB5637263654E /* MinimalistBadgeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MinimalistBadgeView.swift; sourceTree = "<group>"; };
93F078A9D14984485EB28AC9 /* HookRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HookRowView.swift; sourceTree = "<group>"; };
Expand All @@ -334,6 +337,7 @@
A8A8D4BFA46121B6684F74F5 /* AnimationBadgeChrome.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnimationBadgeChrome.swift; sourceTree = "<group>"; };
A977437677E31BB6CF62E58C /* FloatingPetWindowPoolTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloatingPetWindowPoolTests.swift; sourceTree = "<group>"; };
A99F6A33E762533EA412019B /* ConflictBubblePayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConflictBubblePayload.swift; sourceTree = "<group>"; };
A9C3594285193B88B858A1C8 /* FloatingPetPromptBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloatingPetPromptBuilder.swift; sourceTree = "<group>"; };
AA22D5D2131753BBC81766B8 /* SessionNumberAllocator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionNumberAllocator.swift; sourceTree = "<group>"; };
AB2EB6CC13A81EBCFDAF037F /* DemoCycleDriver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoCycleDriver.swift; sourceTree = "<group>"; };
AC5C744A6A25DDB45C495A36 /* SessionLifecycle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionLifecycle.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -547,7 +551,9 @@
AEE399EF550E69D66FBC271D /* FloatingPetInteractionView.swift */,
D574C8088D0CC53510729EAA /* FloatingPetOverlayView.swift */,
22CB181AE0D1FF3EE2035186 /* FloatingPetPanelController.swift */,
A9C3594285193B88B858A1C8 /* FloatingPetPromptBuilder.swift */,
8780B208D0E108DA773D3287 /* FloatingPetPromptCoordinator.swift */,
92F7C601B6A2F543E0A4A246 /* FloatingPetPromptDismissal.swift */,
3351EB8DFFDD6E8E9263B6A8 /* FloatingPetPromptItem.swift */,
E2120EF41902AB522F5AC969 /* GateBadgeLayout.swift */,
D1203CF2C4FF29C31534A7CF /* GateBadgePanel.swift */,
Expand Down Expand Up @@ -837,7 +843,9 @@
2E2D4A6F8E45BCA89918B7F1 /* FloatingPetInteractionView.swift in Sources */,
C290EE1287F115D6FCFA05D8 /* FloatingPetOverlayView.swift in Sources */,
CA717B4026463DF504612273 /* FloatingPetPanelController.swift in Sources */,
69FAC7752922A70F4943791A /* FloatingPetPromptBuilder.swift in Sources */,
11206CBC485103EA118341AB /* FloatingPetPromptCoordinator.swift in Sources */,
59E6748B45FAB395638DCE99 /* FloatingPetPromptDismissal.swift in Sources */,
D0829C6BFE047F8829080BE5 /* FloatingPetPromptItem.swift in Sources */,
21B73048740BFD4E98BB56CB /* FloatingPetScene.swift in Sources */,
58A715CD06913CC033DA019A /* FloatingPetWindowPool.swift in Sources */,
Expand Down
195 changes: 40 additions & 155 deletions apps/menubar/Sources/Windows/FloatingPetInteractionView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,7 @@ final class FloatingPetInteractionView: NSView {
private var isReconfiguringTracking = false
private var resizeCursorPushed = false
private var localMouseMonitor: Any?
private var globalMouseMonitor: Any?
private var globalKeyboardMonitor: Any?
private var hidePromptLocalMouseMonitor: Any?
private var hidePromptDismissObservers: [NSObjectProtocol] = []
private let promptDismissal = FloatingPetPromptDismissal()
private var pointerInsideFrame = false
private var affordanceHoverActive = false
private var hidePromptPanel: FloatingPetHidePromptPanel?
Expand Down Expand Up @@ -153,7 +150,7 @@ final class FloatingPetInteractionView: NSView {

deinit {
removeLocalMouseMonitor()
removeHidePromptDismissObservers()
promptDismissal.uninstall()
}

override func updateTrackingAreas() {
Expand Down Expand Up @@ -470,73 +467,46 @@ final class FloatingPetInteractionView: NSView {
func presentHidePrompt(anchorInScreen: CGPoint, localPoint: CGPoint) {
dismissHidePrompt()
guard let window else { return }
FloatingPetPromptCoordinator.shared.willPresent(owner: self) { [weak self] in
self?.dismissHidePrompt()
}
var items: [FloatingPetPromptItem] = []
// Escape hatch when the pet is stuck mid-animation (rate-limited or
// manually-stopped prompt): sits above "Hide pet" as the primary action.
if isForceIdleAvailable {
items.append(
FloatingPetPromptItem(title: FloatingPetHidePrompt.forceIdleTitle) { [weak self] in
self?.dismissHidePrompt()
self?.forceIdleHandler?()
})
}
// Offered whenever the window is showing a session-label badge at all —
// a session-keyed window's own "Session N" default, or a plain-origin/
// combined window's platform-name default (P?? unification) — not
// just a session-keyed one. Sits above "Hide pet".
if currentSessionLabel != nil {
items.append(
FloatingPetPromptItem(title: FloatingPetHidePrompt.renameTitle) { [weak self] in
self?.dismissHidePrompt()
self?.presentRenameAlert()
})
}
// Only offered on an actual session-keyed window — a plain-origin/
// combined window has no session id to resolve a platform title for.
// Sits directly below "Rename…", grouping the two label actions;
// non-destructive, so it fires immediately on click like Rename does
// once its (separate) text-entry alert is confirmed.
if hasActiveSessionBadge {
items.append(
FloatingPetPromptItem(title: FloatingPetHidePrompt.syncLabelTitle) { [weak self] in
self?.dismissHidePrompt()
self?.syncLabelHandler?()
})
}
// Destroys backing session state (slice, free-list number), so unlike
// Rename this stays restricted to an actual session-keyed window —
// a plain-origin/combined window has no session to prune. Destructive,
// so it sits above "Hide pet" but requires a confirmation alert rather
// than firing immediately on click.
if hasActiveSessionBadge {
items.append(
FloatingPetPromptItem(title: FloatingPetHidePrompt.pruneTitle) { [weak self] in
self?.dismissHidePrompt()
self?.presentPruneConfirmation()
})
}
// Mode switch to the compact badge strip. Sits directly above "Hide pet"
// — like Hide it acts on the window's chrome, not the session's state.
items.append(
FloatingPetPromptItem(title: FloatingPetHidePrompt.minimalistModeTitle) { [weak self] in
let capabilities = FloatingPetPromptCapabilities(
offersForceIdle: isForceIdleAvailable,
sessionLabel: currentSessionLabel,
hasActiveSession: hasActiveSessionBadge,
modeSwitchTitle: FloatingPetHidePrompt.minimalistModeTitle,
offersPanelSize: false,
hideItemTitle: FloatingPetHidePrompt.title
)
let handlers = FloatingPetPromptHandlers(
forceIdle: { [weak self] in
self?.dismissHidePrompt()
self?.forceIdleHandler?()
},
rename: { [weak self] in
self?.dismissHidePrompt()
self?.presentRenameAlert()
},
syncLabel: { [weak self] in
self?.dismissHidePrompt()
self?.syncLabelHandler?()
},
prune: { [weak self] in
self?.dismissHidePrompt()
self?.presentPruneConfirmation()
},
modeSwitch: { [weak self] in
self?.dismissHidePrompt()
self?.minimalistModeHandler?()
})
// Offered unconditionally, directly above "Hide pet" — the last
// action before the pet's own hide.
items.append(
FloatingPetPromptItem(title: FloatingPetHidePrompt.hideAllOtherPetsTitle) { [weak self] in
},
panelSize: {},
hideAllOtherPets: { [weak self] in
self?.dismissHidePrompt()
self?.hideAllOtherPetsHandler?()
})
items.append(
FloatingPetPromptItem(title: FloatingPetHidePrompt.title) { [weak self] in
},
hideThis: { [weak self] in
self?.dismissHidePrompt()
self?.hideFloatingPetHandler?()
})
}
)
let items = FloatingPetPromptBuilder.items(capabilities: capabilities, handlers: handlers)
let promptSize = FloatingPetHidePrompt.stackSize(titles: items.map(\.title))
let visibleFrame = window.screen?.visibleFrame ?? visibleFrameProvider()
let screenFrame = FloatingPetHidePrompt.screenFrame(
Expand All @@ -547,7 +517,9 @@ final class FloatingPetInteractionView: NSView {
let panel = FloatingPetHidePromptPanel(frame: screenFrame, items: items)
panel.orderFrontRegardless()
hidePromptPanel = panel
installHidePromptDismissObservers()
promptDismissal.install(owner: self, panel: panel) { [weak self] in
self?.dismissHidePrompt()
}
}

/// Presents a modal text-entry alert for renaming this session. Trims and
Expand Down Expand Up @@ -606,96 +578,9 @@ final class FloatingPetInteractionView: NSView {
}

private func dismissHidePrompt() {
FloatingPetPromptCoordinator.shared.didDismiss(owner: self)
promptDismissal.uninstall()
hidePromptPanel?.orderOut(nil)
hidePromptPanel = nil
removeHidePromptDismissObservers()
}

private func installHidePromptDismissObservers() {
removeHidePromptDismissObservers()
guard let window else { return }

hidePromptDismissObservers.append(
NotificationCenter.default.addObserver(
forName: NSWindow.didResignKeyNotification,
object: window,
queue: .main
) { [weak self] _ in
self?.dismissHidePrompt()
}
)
hidePromptDismissObservers.append(
NotificationCenter.default.addObserver(
forName: NSApplication.didResignActiveNotification,
object: nil,
queue: .main
) { [weak self] _ in
self?.dismissHidePrompt()
}
)

globalMouseMonitor = NSEvent.addGlobalMonitorForEvents(
matching: [.leftMouseDown, .rightMouseDown]
) { [weak self] event in
Task { @MainActor in
self?.handleGlobalMouseDownWhileHidePromptVisible(event)
}
}

// In-app half of "any click away dismisses": global monitors only
// report other applications' events, so a click on a sibling pet, a
// Minimalist strip, or the menubar icon would strand this prompt
// without a local monitor. Dismissal is synchronous (not
// Task-deferred) — the monitor fires before the event dispatches, and
// a deferred dismissal would land after a right-click's re-present
// and tear down the new prompt instead of the old one.
hidePromptLocalMouseMonitor = NSEvent.addLocalMonitorForEvents(
matching: [.leftMouseDown, .rightMouseDown]
) { [weak self] event in
if let self, event.window !== self.hidePromptPanel {
self.dismissHidePrompt()
}
return event
}

// Dismiss on any keyboard input (including Cmd+Tab / Alt+Tab system
// switchers) so the pill never lingers over the UI while the user is
// changing apps/windows.
globalKeyboardMonitor = NSEvent.addGlobalMonitorForEvents(
matching: [.keyDown, .keyUp, .flagsChanged]
) { [weak self] _ in
Task { @MainActor in
self?.dismissHidePrompt()
}
}
}

private func removeHidePromptDismissObservers() {
for observer in hidePromptDismissObservers {
NotificationCenter.default.removeObserver(observer)
}
hidePromptDismissObservers.removeAll()
if let globalMouseMonitor {
NSEvent.removeMonitor(globalMouseMonitor)
self.globalMouseMonitor = nil
}
if let hidePromptLocalMouseMonitor {
NSEvent.removeMonitor(hidePromptLocalMouseMonitor)
self.hidePromptLocalMouseMonitor = nil
}
if let globalKeyboardMonitor {
NSEvent.removeMonitor(globalKeyboardMonitor)
self.globalKeyboardMonitor = nil
}
}

private func handleGlobalMouseDownWhileHidePromptVisible(_ event: NSEvent) {
guard let hidePromptPanel else { return }
if event.window === hidePromptPanel {
return
}
dismissHidePrompt()
}

private func removeLocalMouseMonitor() {
Expand Down
82 changes: 82 additions & 0 deletions apps/menubar/Sources/Windows/FloatingPetPromptBuilder.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import AppKit

/// Capabilities that vary per window shape (Own vs Minimalist), per
/// `docs/contracts/window-capability-matrix.md` §1. Named per capability, not
/// per shape — Combined is not a distinct capability set here: it inherits
/// whichever shape it is currently routed through and calls the builder with
/// that shape's capabilities.
struct FloatingPetPromptCapabilities {
/// R1.2 — pet/badge is not idle.
var offersForceIdle: Bool
/// R1.3 gate — the window's current session-label badge text, or `nil`
/// when no label is shown at all. Rename… is offered whenever non-nil,
/// independent of `hasActiveSession` (a plain-origin/combined window can
/// carry a label without being session-keyed).
var sessionLabel: String?
/// R1.4/R1.5 gate — an actual session-keyed window (not plain-origin or
/// combined); offers Sync Label and Prune Session.
var hasActiveSession: Bool
/// R1.6 — "Minimalist Mode" (Own) or "Pet Mode" (Minimalist).
var modeSwitchTitle: String
/// R1.7 — Minimalist-only "Panel Size…", unconditional when present; Own
/// has no analogous size concept.
var offersPanelSize: Bool
/// R1.9 — "Hide pet" (Own) or "Hide panel" (Minimalist); same semantics,
/// cosmetic title only.
var hideItemTitle: String
}

/// Activation closures for every possible prompt item. Each closure is
/// expected to already perform any dismiss-on-activate the caller needs — the
/// builder only decides which items exist and in what order.
struct FloatingPetPromptHandlers {
var forceIdle: () -> Void
var rename: () -> Void
var syncLabel: () -> Void
var prune: () -> Void
var modeSwitch: () -> Void
var panelSize: () -> Void
var hideAllOtherPets: () -> Void
var hideThis: () -> Void
}

/// The single `[FloatingPetPromptItem]` builder for the right-click hide
/// prompt, shared by Own (`FloatingPetInteractionView`) and Minimalist
/// (`MinimalistBadgeView`). Per-shape differences are expressed only as
/// `FloatingPetPromptCapabilities` fields — never as shape identity.
enum FloatingPetPromptBuilder {
static func items(
capabilities: FloatingPetPromptCapabilities,
handlers: FloatingPetPromptHandlers
) -> [FloatingPetPromptItem] {
var items: [FloatingPetPromptItem] = []

if capabilities.offersForceIdle {
items.append(
FloatingPetPromptItem(title: FloatingPetHidePrompt.forceIdleTitle, onActivate: handlers.forceIdle))
}
if capabilities.sessionLabel != nil {
items.append(
FloatingPetPromptItem(title: FloatingPetHidePrompt.renameTitle, onActivate: handlers.rename))
}
if capabilities.hasActiveSession {
items.append(
FloatingPetPromptItem(title: FloatingPetHidePrompt.syncLabelTitle, onActivate: handlers.syncLabel))
items.append(
FloatingPetPromptItem(title: FloatingPetHidePrompt.pruneTitle, onActivate: handlers.prune))
}
items.append(
FloatingPetPromptItem(title: capabilities.modeSwitchTitle, onActivate: handlers.modeSwitch))
if capabilities.offersPanelSize {
items.append(
FloatingPetPromptItem(title: FloatingPetHidePrompt.panelSizeTitle, onActivate: handlers.panelSize))
}
items.append(
FloatingPetPromptItem(
title: FloatingPetHidePrompt.hideAllOtherPetsTitle, onActivate: handlers.hideAllOtherPets))
items.append(
FloatingPetPromptItem(title: capabilities.hideItemTitle, onActivate: handlers.hideThis))

return items
}
}
Loading