From b81ed7b17add142fb56e92c7295e3904e333045b Mon Sep 17 00:00:00 2001 From: Hatter Jiang Date: Mon, 6 Jul 2026 01:34:47 +0800 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor=20the=20notificat?= =?UTF-8?q?ion=20panel=20to=20a=20non-modal=20architecture=20and=20replace?= =?UTF-8?q?=20the=20time=20input=20with=20a=20dropdown=20menu=20for=20snoo?= =?UTF-8?q?ze=20presets.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/NotificationService/AppDelegate.swift | 28 +- .../NotificationPanel.swift | 242 +++++++++++------- 2 files changed, 172 insertions(+), 98 deletions(-) diff --git a/Sources/NotificationService/AppDelegate.swift b/Sources/NotificationService/AppDelegate.swift index afd68b2..9063cab 100644 --- a/Sources/NotificationService/AppDelegate.swift +++ b/Sources/NotificationService/AppDelegate.swift @@ -17,6 +17,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private var server: HTTPServer? private var pending: [Pending] = [] private var showing = false + /// Retains the in-flight panel controller (the panel is non-modal, so + /// nothing else keeps it alive while the user interacts). + private var currentPanel: NotificationPanel? func applicationDidFinishLaunching(_ notification: Notification) { if let icon = AppIcon.image { @@ -92,17 +95,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate { let panel = NotificationPanel(title: next.title, content: next.content, defaultMinutes: 5) panel.windowLevel = .floating - let result = panel.runModal() - - if result == .remind { - // Save the content and re-fire after the chosen delay. Strip any - // existing [snoozed] suffix so repeated snoozes don't accumulate it. - SnoozeStore.shared.schedule(title: SnoozeStore.stripSuffix(next.title), - content: next.content, - minutes: panel.minutes) + currentPanel = panel + panel.show { [weak self] result, minutes in + guard let self = self else { return } + if result == .remind { + // Save the content and re-fire after the chosen delay. Strip + // any existing [snoozed] suffix so repeated snoozes don't + // accumulate it. + SnoozeStore.shared.schedule(title: SnoozeStore.stripSuffix(next.title), + content: next.content, + minutes: minutes) + } + self.currentPanel = nil + self.showing = false + self.showNext() } - - showing = false - showNext() } } diff --git a/Sources/NotificationService/NotificationPanel.swift b/Sources/NotificationService/NotificationPanel.swift index 09df66f..4e84985 100644 --- a/Sources/NotificationService/NotificationPanel.swift +++ b/Sources/NotificationService/NotificationPanel.swift @@ -1,21 +1,41 @@ import Foundation import AppKit -/// A centered, rounded modal card that replaces the stock NSAlert for a -/// nicer-looking notification with a snooze control. Dark-mode aware via -/// semantic system colors; floats above other apps' windows. +/// A centered, rounded modal-looking card that replaces the stock NSAlert. +/// Non-modal (uses a completion handler) so that the snooze popup menu works — +/// menus popped up inside a blocking `NSApp.runModal` session end up greyed out. +/// +/// Bottom action row is a split snooze button: +/// +/// [Remind after 5 min][▾] [Dismiss] +/// +/// Clicking the main button snoozes with the default delay; the chevron opens +/// a menu of preset delays (and "Custom…" for any 1–120 value); Dismiss closes. final class NotificationPanel { enum Result { case dismiss, remind } private let panel: Panel private let card: CardView - private let minutesField: NSTextField - private let stepper: NSStepper + private let moreButton: NSButton private let remindButton: NSButton + private let defaultMinutes: Int private var result: Result = .dismiss - private(set) var minutes: Int + private var minutes: Int + + private var completion: ((Result, Int) -> Void)? + + /// Set by menu items while the popup is open; applied after it closes. + private var pendingAction: PendingAction = .none + + private enum PendingAction { + case none + case remind(Int) + case custom + } + + private let presets = [1, 5, 10, 15, 30, 60, 120] var windowLevel: NSWindow.Level { get { panel.level } @@ -23,7 +43,8 @@ final class NotificationPanel { } init(title: String, content: String, defaultMinutes: Int) { - minutes = max(1, min(120, defaultMinutes)) + self.defaultMinutes = max(1, min(120, defaultMinutes)) + minutes = self.defaultMinutes panel = Panel(contentRect: NSRect(x: 0, y: 0, width: 380, height: 400), styleMask: [], backing: .buffered, defer: false) @@ -39,7 +60,7 @@ final class NotificationPanel { panel.contentView = card card.widthAnchor.constraint(equalToConstant: 380).isActive = true - // ---- Subviews ---- + // ---- Header: bell icon + title ---- let icon = NSImageView() icon.image = AppIcon.image icon.imageScaling = .scaleProportionallyUpOrDown @@ -63,6 +84,7 @@ final class NotificationPanel { header.translatesAutoresizingMaskIntoConstraints = false header.setContentHuggingPriority(.defaultLow, for: .horizontal) + // ---- Body ---- let contentField = NSTextField(labelWithString: content) contentField.font = .systemFont(ofSize: 13) contentField.textColor = .secondaryLabelColor @@ -74,69 +96,41 @@ final class NotificationPanel { contentField.setContentHuggingPriority(.defaultLow, for: .horizontal) contentField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - minutesField = NSTextField(string: "\(minutes)") - minutesField.font = .systemFont(ofSize: 12, weight: .medium) - minutesField.alignment = .center - minutesField.translatesAutoresizingMaskIntoConstraints = false - minutesField.widthAnchor.constraint(equalToConstant: 38).isActive = true - let fmt = NumberFormatter() - fmt.numberStyle = .none - fmt.usesGroupingSeparator = false - fmt.minimum = 1 - fmt.maximum = 120 - fmt.isLenient = true - minutesField.formatter = fmt + // ---- Action row: [spacer] [Remind after N min][▾] [Dismiss] ---- + remindButton = NSButton(title: "Remind after \(defaultMinutes) min", + target: nil, action: nil) + remindButton.bezelStyle = .rounded + remindButton.keyEquivalent = "\r" // Return -> default (blue) + remindButton.translatesAutoresizingMaskIntoConstraints = false + remindButton.setContentHuggingPriority(.defaultHigh, for: .horizontal) - stepper = NSStepper() - stepper.minValue = 1 - stepper.maxValue = 120 - stepper.increment = 1 - stepper.doubleValue = Double(minutes) - stepper.valueWraps = false - stepper.autorepeat = true - stepper.translatesAutoresizingMaskIntoConstraints = false - - let snoozeLabel = NSTextField(labelWithString: "Remind me in") - snoozeLabel.font = .systemFont(ofSize: 12) - snoozeLabel.textColor = .secondaryLabelColor - snoozeLabel.translatesAutoresizingMaskIntoConstraints = false - - let minLabel = NSTextField(labelWithString: "minutes (1–120)") - minLabel.font = .systemFont(ofSize: 12) - minLabel.textColor = .secondaryLabelColor - minLabel.translatesAutoresizingMaskIntoConstraints = false - - let snoozeRow = NSStackView(views: [snoozeLabel, minutesField, stepper, minLabel]) - snoozeRow.orientation = .horizontal - snoozeRow.alignment = .centerY - snoozeRow.spacing = 8 - snoozeRow.translatesAutoresizingMaskIntoConstraints = false - snoozeRow.heightAnchor.constraint(equalToConstant: 24).isActive = true + moreButton = NSButton(image: NSImage(systemSymbolName: "chevron.down", + accessibilityDescription: "More delay options")!, + target: nil, action: nil) + moreButton.bezelStyle = .rounded + moreButton.imagePosition = .imageOnly + moreButton.translatesAutoresizingMaskIntoConstraints = false let dismissButton = NSButton(title: "Dismiss", target: nil, action: nil) dismissButton.bezelStyle = .rounded dismissButton.keyEquivalent = "\u{1b}" // Esc dismissButton.translatesAutoresizingMaskIntoConstraints = false - - remindButton = NSButton(title: "Remind Me", target: nil, action: nil) - remindButton.bezelStyle = .rounded - remindButton.keyEquivalent = "\r" // Return -> default (blue) - remindButton.translatesAutoresizingMaskIntoConstraints = false + dismissButton.setContentHuggingPriority(.defaultHigh, for: .horizontal) let spacer = NSView() spacer.translatesAutoresizingMaskIntoConstraints = false spacer.setContentHuggingPriority(.defaultLow, for: .horizontal) spacer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - let buttonRow = NSStackView(views: [spacer, dismissButton, remindButton]) - buttonRow.orientation = .horizontal - buttonRow.alignment = .centerY - buttonRow.spacing = 10 - buttonRow.translatesAutoresizingMaskIntoConstraints = false - buttonRow.setContentHuggingPriority(.defaultLow, for: .horizontal) + let actionRow = NSStackView(views: [spacer, remindButton, moreButton, dismissButton]) + actionRow.orientation = .horizontal + actionRow.alignment = .centerY + actionRow.spacing = 8 + actionRow.translatesAutoresizingMaskIntoConstraints = false + actionRow.setContentHuggingPriority(.defaultLow, for: .horizontal) // ---- Vertical stack ---- - let stack = NSStackView(views: [header, contentField, snoozeRow, buttonRow]) + let stack = NSStackView(views: [header, contentField, actionRow]) stack.orientation = .vertical stack.alignment = .leading stack.spacing = 14 @@ -147,64 +141,140 @@ final class NotificationPanel { card.trailingAnchor.constraint(equalTo: stack.trailingAnchor, constant: 24), stack.topAnchor.constraint(equalTo: card.topAnchor, constant: 24), card.bottomAnchor.constraint(equalTo: stack.bottomAnchor, constant: 24), - // span full width where needed header.widthAnchor.constraint(equalTo: stack.widthAnchor), contentField.widthAnchor.constraint(equalTo: stack.widthAnchor), - buttonRow.widthAnchor.constraint(equalTo: stack.widthAnchor), + actionRow.widthAnchor.constraint(equalTo: stack.widthAnchor), ]) - // Wire targets now that self is initialized. - stepper.target = self - stepper.action = #selector(stepperChanged) - minutesField.target = self - minutesField.action = #selector(fieldChanged) - dismissButton.target = self - dismissButton.action = #selector(dismissTapped) + // Wire targets. remindButton.target = self remindButton.action = #selector(remindTapped) + moreButton.target = self + moreButton.action = #selector(moreTapped) + dismissButton.target = self + dismissButton.action = #selector(dismissTapped) } - // MARK: - Run + // MARK: - Show - func runModal() -> Result { + /// Present the panel centered on screen. Non-modal: returns immediately, + /// `completion` is called when the user picks an action. + func show(completion: @escaping (Result, Int) -> Void) { + self.completion = completion NSApp.activate(ignoringOtherApps: true) card.layoutSubtreeIfNeeded() let fit = card.fittingSize panel.setContentSize(NSSize(width: 380, height: fit.height)) panel.center() panel.makeFirstResponder(remindButton) - _ = NSApp.runModal(for: panel) + panel.makeKeyAndOrderFront(nil) + } + + private func finish() { panel.orderOut(nil) - return result + let cb = completion + completion = nil + cb?(result, minutes) } // MARK: - Actions + @objc private func remindTapped() { + minutes = defaultMinutes + result = .remind + finish() + } + @objc private func dismissTapped() { result = .dismiss - NSApp.stopModal() + finish() } - @objc private func remindTapped() { - panel.makeFirstResponder(nil) // commit field editing - minutes = clampedMinutes() - result = .remind - NSApp.stopModal() + @objc private func moreTapped() { + pendingAction = .none + let menu = buildMenu() + // Pop up just below the chevron. + let point = NSPoint(x: 0, y: moreButton.bounds.height + 2) + menu.popUp(positioning: nil, at: point, in: moreButton) + + // Menu closed — apply whatever was chosen (if anything). + switch pendingAction { + case .none: + break + case .remind(let m): + minutes = m + result = .remind + finish() + case .custom: + if let m = promptCustomMinutes() { + minutes = m + result = .remind + finish() + } + } } - @objc private func stepperChanged() { - minutesField.intValue = stepper.intValue + @objc private func presetTapped(_ sender: NSMenuItem) { + pendingAction = .remind(max(1, min(120, sender.tag))) } - @objc private func fieldChanged() { - stepper.intValue = Int32(clampedMinutes()) + @objc private func customTapped() { + pendingAction = .custom } - private func clampedMinutes() -> Int { - var v = Int(minutesField.intValue) - if v < 1 { v = 1 } - if v > 120 { v = 120 } - return v + // MARK: - Menu / custom input + + private func buildMenu() -> NSMenu { + let menu = NSMenu() + // We set explicit targets, so disable AppKit's auto-enable (which + // greyed items out under the old modal session). + menu.autoenablesItems = false + for m in presets { + let item = NSMenuItem(title: "Remind after \(m) min", + action: #selector(presetTapped(_:)), keyEquivalent: "") + item.target = self + item.tag = m + item.isEnabled = true + if m == defaultMinutes { item.state = .on } + menu.addItem(item) + } + menu.addItem(.separator()) + let custom = NSMenuItem(title: "Custom…", + action: #selector(customTapped), keyEquivalent: "") + custom.target = self + custom.isEnabled = true + menu.addItem(custom) + return menu + } + + /// Modal alert prompting for an arbitrary 1–120 minute delay. + private func promptCustomMinutes() -> Int? { + let alert = NSAlert() + alert.messageText = "Remind after" + alert.informativeText = "Delay in minutes (1–120):" + alert.addButton(withTitle: "Remind") + alert.addButton(withTitle: "Cancel") + alert.icon = AppIcon.image + alert.window.level = panel.level + + let field = NSTextField(string: "\(defaultMinutes)") + field.widthAnchor.constraint(equalToConstant: 80).isActive = true + let fmt = NumberFormatter() + fmt.numberStyle = .none + fmt.usesGroupingSeparator = false + fmt.minimum = 1 + fmt.maximum = 120 + fmt.isLenient = true + field.formatter = fmt + alert.accessoryView = field + + if alert.runModal() == .alertFirstButtonReturn { + var v = Int(field.intValue) + if v < 1 { v = 1 } + if v > 120 { v = 120 } + return v + } + return nil } } @@ -215,8 +285,6 @@ private final class Panel: NSPanel { override var canBecomeMain: Bool { true } } -/// Rounded, opaque-on-clear card background. Adapts to light/dark via system -/// colors; the surrounding window is transparent so corners show through. private final class CardView: NSView { override var isFlipped: Bool { true } override func draw(_ dirty: NSRect) {