Something went wrong. Try again.
Monorepo for Aesthetic.Computer aesthetic.computer
Something went wrong. Try again.
13 kB · 265 lines
Swift
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266// MenuBarDays.swift — DateWizard's always-on menu bar presence.//// A strip of seven circles (Sun→Sat: S M T W T F S) painted in the shared// ROYGBIV DayPalette (see DayStrip for the drawing). The current production// face is the wand + next-appointment countdown; left-click opens the agenda// and right-click drops the wizard menu.import AppKit
// NSResponder shim that forwards tracking-area mouse events to closures// (NSTrackingArea.owner has to be an NSResponder). Mirrors Menu Band's// HoverResponder so the two apps track hover the same way.private final class HoverResponder: NSResponder { var onMove: ((NSEvent) -> Void)? var onExit: (() -> Void)? override func mouseEntered(with event: NSEvent) { onMove?(event) } override func mouseMoved(with event: NSEvent) { onMove?(event) } override func mouseExited(with event: NSEvent) { onExit?() }}
final class MenuBarDays {
// Callbacks wired by the AppDelegate. var onOpen: (() -> Void)? var onToggle: (() -> Void)? var onToday: (() -> Void)?
private var statusItem: NSStatusItem! private weak var button: NSStatusBarButton? private let hover = HoverResponder() private var hoveredIndex: Int? private var focusedIndex: Int? // the day showing in the wizard private var nextEventDate: Date? // start of the next appointment (badge) private var nextEventTitle: String? // name of that appointment (tooltip) private var countdownTimer: Timer? // ticks the badge text down private var midnightTimer: Timer? // Menu-bar-fit rung: 2 = full countdown pill, 1 = presence dot, 0 = bare // wand. Driven by the shared negotiation bus (MenuBarFit) — under menu-bar // pressure the wand sheds its badge before other apps lose more. private var fitRung = 2 private var fit: MenuBarFit? private var barThickness: CGFloat = 22 // The daemon's roster of sibling wizards it can summon (Date is built in). private let roster = WizardRoster()
// ── lifecycle ───────────────────────────────────────────────────── func install() { barThickness = NSStatusBar.system.thickness // Pin the wand LEFT of Menu Band. NSStatusItem has no absolute-ordering // API; its autosaveName + "Preferred Position" default is the lever macOS // honors (higher = further left). Seed once; a user ⌘-drag persists over it. let posKey = "NSStatusItem Preferred Position datewizard" if UserDefaults.standard.object(forKey: posKey) == nil { UserDefaults.standard.set(24, forKey: posKey) } statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) statusItem.autosaveName = "datewizard" if let button = statusItem.button { self.button = button button.imagePosition = .imageOnly button.target = self button.action = #selector(clicked(_:)) button.sendAction(on: [.leftMouseUp, .rightMouseUp]) button.toolTip = "Wizard — calendar + summon (right-click)"
// Hover tracking — lights the dot under the cursor. hover.onMove = { [weak self] ev in self?.handleHover(ev) } hover.onExit = { [weak self] in self?.handleHoverExit() } let area = NSTrackingArea( rect: button.bounds, options: [.mouseMoved, .mouseEnteredAndExited, .activeAlways, .inVisibleRect], owner: hover, userInfo: nil) button.addTrackingArea(area) } refresh() scheduleMidnightRefresh() scheduleCountdownTick() startFitNegotiation() // Redraw when the system flips light/dark so the (non-template) badged // wand re-tints correctly. DistributedNotificationCenter.default().addObserver( self, selector: #selector(appearanceChanged), name: NSNotification.Name("AppleInterfaceThemeChangedNotification"), object: nil) }
@objc private func appearanceChanged() { refresh() }
/// Highlight the day currently focused in the wizard (nil = none/today). func setFocusedDay(_ date: Date?) { let idx = date.map { DayPalette.index(for: $0) } if idx != focusedIndex { focusedIndex = idx; refresh() } }
/// The start of the next appointment (nil = nothing ahead). Drives the /// countdown badge on the wand. Called by the wizard whenever it reloads /// the upcoming set. func setNextEvent(_ date: Date?, title: String? = nil) { let had = (nextEventDate != nil) nextEventTitle = title if date != nextEventDate { nextEventDate = date; refresh() } else { button?.toolTip = nextEventToolTip() } // same time, refreshed name // When an event appears/disappears, reshape the fit ladder so the broker // knows whether there's a badge to trade for space. if (date != nil) != had { fit?.updateRungs(fitRungs(hasNext: date != nil)) } }
/// Short, unit-labeled countdown to the next appointment: the two most /// significant non-zero units ("2d3h", "20h41m", "5m30s", "45s"), "now" at /// zero. Seconds tick live once the event is under an hour away. private func countdownText() -> String? { guard let d = nextEventDate else { return nil } let secs = Int(d.timeIntervalSinceNow) if secs <= 0 { return "now" } let days = secs / 86400, h = (secs % 86400) / 3600, m = (secs % 3600) / 60, s = secs % 60 if days > 0 { return "\(days)d\(h)h" } if h > 0 { return "\(h)h\(m)m" } if m > 0 { return "\(m)m\(s)s" } return "\(s)s" }
// Re-render the badge every second so the H:MM:SS readout ticks live. It's a // tiny image redraw, and the wand sheds the badge under menu-bar pressure. private func scheduleCountdownTick() { countdownTimer?.invalidate() let timer = Timer(timeInterval: 1, repeats: true) { [weak self] _ in self?.refresh() } RunLoop.main.add(timer, forMode: .common) countdownTimer = timer }
/// Paint the menu-bar face: a plain black magic wand (template image, so it /// tints to the bar like Menu Band's note glyph). This daemon is the "wizard /// of wizards" now (the date is built in) — the costumed wizardGuy lives on /// only inside the menu/About, where color reads fine. func refresh() { guard let button = statusItem?.button else { return } let dark = button.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua let hasNext = (nextEventDate != nil) switch fitRung { case 2 where hasNext: button.image = wandGlyph(badge: countdownText(), dark: dark) case 1 where hasNext: button.image = wandGlyph(dot: true, dark: dark) default: button.image = wandGlyph(dark: dark) // bare } button.toolTip = nextEventToolTip() }
// ── menu-bar-fit negotiation ────────────────────────────────────── // The wand's ladder: bare → presence-dot → full countdown pill. With no // upcoming event the ladder collapses to just the bare wand so the broker's // width model stays honest (nothing to shed). High priority (40): the wand's // countdown is the thing to defend, so Menu Band gives up piano keys before // DateWizard sheds the badge. private func startFitNegotiation() { guard let statusItem, fit == nil else { return } let has = (nextEventDate != nil) fit = MenuBarFit(slug: "datewizard", priority: 40, rungs: fitRungs(hasNext: has), statusItem: statusItem, startAt: has ? 2 : 0) { [weak self] _, idx in self?.fitRung = idx self?.refresh() } fit?.start() }
private func fitRungs(hasNext: Bool) -> [MenuBarFit.Rung] { hasNext ? [.init(name: "bare", width: 24), .init(name: "dot", width: 32), .init(name: "badge", width: 46)] : [.init(name: "bare", width: 24)] }
private func nextEventToolTip() -> String { let base = "Wizard — calendar + summon (right-click)" guard let d = nextEventDate else { return base } let fmt = DateFormatter(); fmt.dateStyle = .none; fmt.timeStyle = .short let cal = Calendar.current let dayWord: String if cal.isDateInToday(d) { dayWord = "" } else if cal.isDateInTomorrow(d) { dayWord = "tomorrow " } else { let df = DateFormatter(); df.dateFormat = "EEE "; dayWord = df.string(from: d) } let name = (nextEventTitle?.isEmpty == false) ? nextEventTitle! : "appointment" return "\(name) — \(dayWord)\(fmt.string(from: d))\n\(base)" }
// ── input ───────────────────────────────────────────────────────── @objc private func clicked(_ sender: NSStatusBarButton) { // Right-click → the full daemon menu (calendar controls + summon roster). // Left-click → straight into the calendar (the thing you reach for most). if NSApp.currentEvent?.type == .rightMouseUp { showMenu(from: sender) } else { onToggle?() } }
private func handleHover(_ event: NSEvent) { guard let button else { return } let local = button.convert(event.locationInWindow, from: nil) let idx = DayStrip.index(atX: local.x, in: button.bounds) if idx != hoveredIndex { hoveredIndex = idx; refresh() } }
private func handleHoverExit() { if hoveredIndex != nil { hoveredIndex = nil; refresh() } }
private func showMenu(from button: NSStatusBarButton) { let menu = NSMenu()
// ── The date, built in ── let open = NSMenuItem(title: "Open Calendar", action: #selector(menuOpen), keyEquivalent: "") open.target = self open.image = wizardGuy(scale: 1) menu.addItem(open) let today = NSMenuItem(title: "Go to Today", action: #selector(menuToday), keyEquivalent: "") today.target = self menu.addItem(today)
// ── Summon a sibling wizard ── menu.addItem(.separator()) let heading = NSMenuItem(title: "Summon", action: nil, keyEquivalent: "") heading.isEnabled = false menu.addItem(heading) for (i, w) in siblingWizards.enumerated() { let item = NSMenuItem(title: w.exe, action: #selector(WizardRoster.summon(_:)), keyEquivalent: "") item.target = roster item.tag = i item.toolTip = w.blurb item.image = roster.mascotImage(w, side: 18) menu.addItem(item) }
// ── About / quit ── menu.addItem(.separator()) let about = NSMenuItem(title: "About Our Wizards…", action: #selector(WizardRoster.showAbout), keyEquivalent: "") about.target = roster menu.addItem(about) let quit = NSMenuItem(title: "Quit Wizard", action: #selector(menuQuit), keyEquivalent: "q") quit.target = self menu.addItem(quit)
menu.popUp(positioning: nil, at: NSPoint(x: 0, y: button.bounds.height + 4), in: button) }
@objc private func menuOpen() { onOpen?() } @objc private func menuToday() { onToday?() } @objc private func menuQuit() { NSApp.terminate(nil) }
// Refresh the strip exactly when the local day rolls over, then again // the next midnight, indefinitely. private func scheduleMidnightRefresh() { let cal = Calendar.current let now = Date() let nextMidnight = cal.nextDate(after: now, matching: DateComponents(hour: 0, minute: 0, second: 1), matchingPolicy: .nextTime) ?? now.addingTimeInterval(86400) midnightTimer?.invalidate() let timer = Timer(fire: nextMidnight, interval: 0, repeats: false) { [weak self] _ in self?.refresh() self?.scheduleMidnightRefresh() } RunLoop.main.add(timer, forMode: .common) midnightTimer = timer }}