♻️ Refactor the notification panel to a non-modal architecture and replace the time input with a dropdown menu for snooze presets.

This commit is contained in:
2026-07-06 01:34:47 +08:00
parent ea960f2dfa
commit b81ed7b17a
2 changed files with 172 additions and 98 deletions
+17 -11
View File
@@ -17,6 +17,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
private var server: HTTPServer? private var server: HTTPServer?
private var pending: [Pending] = [] private var pending: [Pending] = []
private var showing = false 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) { func applicationDidFinishLaunching(_ notification: Notification) {
if let icon = AppIcon.image { 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) let panel = NotificationPanel(title: next.title, content: next.content, defaultMinutes: 5)
panel.windowLevel = .floating panel.windowLevel = .floating
let result = panel.runModal() currentPanel = panel
panel.show { [weak self] result, minutes in
if result == .remind { guard let self = self else { return }
// Save the content and re-fire after the chosen delay. Strip any if result == .remind {
// existing [snoozed] suffix so repeated snoozes don't accumulate it. // Save the content and re-fire after the chosen delay. Strip
SnoozeStore.shared.schedule(title: SnoozeStore.stripSuffix(next.title), // any existing [snoozed] suffix so repeated snoozes don't
content: next.content, // accumulate it.
minutes: panel.minutes) SnoozeStore.shared.schedule(title: SnoozeStore.stripSuffix(next.title),
content: next.content,
minutes: minutes)
}
self.currentPanel = nil
self.showing = false
self.showNext()
} }
showing = false
showNext()
} }
} }
@@ -1,21 +1,41 @@
import Foundation import Foundation
import AppKit import AppKit
/// A centered, rounded modal card that replaces the stock NSAlert for a /// A centered, rounded modal-looking card that replaces the stock NSAlert.
/// nicer-looking notification with a snooze control. Dark-mode aware via /// Non-modal (uses a completion handler) so that the snooze popup menu works
/// semantic system colors; floats above other apps' windows. /// 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 1120 value); Dismiss closes.
final class NotificationPanel { final class NotificationPanel {
enum Result { case dismiss, remind } enum Result { case dismiss, remind }
private let panel: Panel private let panel: Panel
private let card: CardView private let card: CardView
private let minutesField: NSTextField private let moreButton: NSButton
private let stepper: NSStepper
private let remindButton: NSButton private let remindButton: NSButton
private let defaultMinutes: Int
private var result: Result = .dismiss 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 { var windowLevel: NSWindow.Level {
get { panel.level } get { panel.level }
@@ -23,7 +43,8 @@ final class NotificationPanel {
} }
init(title: String, content: String, defaultMinutes: Int) { 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), panel = Panel(contentRect: NSRect(x: 0, y: 0, width: 380, height: 400),
styleMask: [], backing: .buffered, defer: false) styleMask: [], backing: .buffered, defer: false)
@@ -39,7 +60,7 @@ final class NotificationPanel {
panel.contentView = card panel.contentView = card
card.widthAnchor.constraint(equalToConstant: 380).isActive = true card.widthAnchor.constraint(equalToConstant: 380).isActive = true
// ---- Subviews ---- // ---- Header: bell icon + title ----
let icon = NSImageView() let icon = NSImageView()
icon.image = AppIcon.image icon.image = AppIcon.image
icon.imageScaling = .scaleProportionallyUpOrDown icon.imageScaling = .scaleProportionallyUpOrDown
@@ -63,6 +84,7 @@ final class NotificationPanel {
header.translatesAutoresizingMaskIntoConstraints = false header.translatesAutoresizingMaskIntoConstraints = false
header.setContentHuggingPriority(.defaultLow, for: .horizontal) header.setContentHuggingPriority(.defaultLow, for: .horizontal)
// ---- Body ----
let contentField = NSTextField(labelWithString: content) let contentField = NSTextField(labelWithString: content)
contentField.font = .systemFont(ofSize: 13) contentField.font = .systemFont(ofSize: 13)
contentField.textColor = .secondaryLabelColor contentField.textColor = .secondaryLabelColor
@@ -74,69 +96,41 @@ final class NotificationPanel {
contentField.setContentHuggingPriority(.defaultLow, for: .horizontal) contentField.setContentHuggingPriority(.defaultLow, for: .horizontal)
contentField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) contentField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
minutesField = NSTextField(string: "\(minutes)") // ---- Action row: [spacer] [Remind after N min][] [Dismiss] ----
minutesField.font = .systemFont(ofSize: 12, weight: .medium) remindButton = NSButton(title: "Remind after \(defaultMinutes) min",
minutesField.alignment = .center target: nil, action: nil)
minutesField.translatesAutoresizingMaskIntoConstraints = false remindButton.bezelStyle = .rounded
minutesField.widthAnchor.constraint(equalToConstant: 38).isActive = true remindButton.keyEquivalent = "\r" // Return -> default (blue)
let fmt = NumberFormatter() remindButton.translatesAutoresizingMaskIntoConstraints = false
fmt.numberStyle = .none remindButton.setContentHuggingPriority(.defaultHigh, for: .horizontal)
fmt.usesGroupingSeparator = false
fmt.minimum = 1
fmt.maximum = 120
fmt.isLenient = true
minutesField.formatter = fmt
stepper = NSStepper() moreButton = NSButton(image: NSImage(systemSymbolName: "chevron.down",
stepper.minValue = 1 accessibilityDescription: "More delay options")!,
stepper.maxValue = 120 target: nil, action: nil)
stepper.increment = 1 moreButton.bezelStyle = .rounded
stepper.doubleValue = Double(minutes) moreButton.imagePosition = .imageOnly
stepper.valueWraps = false moreButton.translatesAutoresizingMaskIntoConstraints = 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 (1120)")
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
let dismissButton = NSButton(title: "Dismiss", target: nil, action: nil) let dismissButton = NSButton(title: "Dismiss", target: nil, action: nil)
dismissButton.bezelStyle = .rounded dismissButton.bezelStyle = .rounded
dismissButton.keyEquivalent = "\u{1b}" // Esc dismissButton.keyEquivalent = "\u{1b}" // Esc
dismissButton.translatesAutoresizingMaskIntoConstraints = false dismissButton.translatesAutoresizingMaskIntoConstraints = false
dismissButton.setContentHuggingPriority(.defaultHigh, for: .horizontal)
remindButton = NSButton(title: "Remind Me", target: nil, action: nil)
remindButton.bezelStyle = .rounded
remindButton.keyEquivalent = "\r" // Return -> default (blue)
remindButton.translatesAutoresizingMaskIntoConstraints = false
let spacer = NSView() let spacer = NSView()
spacer.translatesAutoresizingMaskIntoConstraints = false spacer.translatesAutoresizingMaskIntoConstraints = false
spacer.setContentHuggingPriority(.defaultLow, for: .horizontal) spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
spacer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) spacer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
let buttonRow = NSStackView(views: [spacer, dismissButton, remindButton]) let actionRow = NSStackView(views: [spacer, remindButton, moreButton, dismissButton])
buttonRow.orientation = .horizontal actionRow.orientation = .horizontal
buttonRow.alignment = .centerY actionRow.alignment = .centerY
buttonRow.spacing = 10 actionRow.spacing = 8
buttonRow.translatesAutoresizingMaskIntoConstraints = false actionRow.translatesAutoresizingMaskIntoConstraints = false
buttonRow.setContentHuggingPriority(.defaultLow, for: .horizontal) actionRow.setContentHuggingPriority(.defaultLow, for: .horizontal)
// ---- Vertical stack ---- // ---- Vertical stack ----
let stack = NSStackView(views: [header, contentField, snoozeRow, buttonRow]) let stack = NSStackView(views: [header, contentField, actionRow])
stack.orientation = .vertical stack.orientation = .vertical
stack.alignment = .leading stack.alignment = .leading
stack.spacing = 14 stack.spacing = 14
@@ -147,64 +141,140 @@ final class NotificationPanel {
card.trailingAnchor.constraint(equalTo: stack.trailingAnchor, constant: 24), card.trailingAnchor.constraint(equalTo: stack.trailingAnchor, constant: 24),
stack.topAnchor.constraint(equalTo: card.topAnchor, constant: 24), stack.topAnchor.constraint(equalTo: card.topAnchor, constant: 24),
card.bottomAnchor.constraint(equalTo: stack.bottomAnchor, constant: 24), card.bottomAnchor.constraint(equalTo: stack.bottomAnchor, constant: 24),
// span full width where needed
header.widthAnchor.constraint(equalTo: stack.widthAnchor), header.widthAnchor.constraint(equalTo: stack.widthAnchor),
contentField.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. // Wire targets.
stepper.target = self
stepper.action = #selector(stepperChanged)
minutesField.target = self
minutesField.action = #selector(fieldChanged)
dismissButton.target = self
dismissButton.action = #selector(dismissTapped)
remindButton.target = self remindButton.target = self
remindButton.action = #selector(remindTapped) 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) NSApp.activate(ignoringOtherApps: true)
card.layoutSubtreeIfNeeded() card.layoutSubtreeIfNeeded()
let fit = card.fittingSize let fit = card.fittingSize
panel.setContentSize(NSSize(width: 380, height: fit.height)) panel.setContentSize(NSSize(width: 380, height: fit.height))
panel.center() panel.center()
panel.makeFirstResponder(remindButton) panel.makeFirstResponder(remindButton)
_ = NSApp.runModal(for: panel) panel.makeKeyAndOrderFront(nil)
}
private func finish() {
panel.orderOut(nil) panel.orderOut(nil)
return result let cb = completion
completion = nil
cb?(result, minutes)
} }
// MARK: - Actions // MARK: - Actions
@objc private func remindTapped() {
minutes = defaultMinutes
result = .remind
finish()
}
@objc private func dismissTapped() { @objc private func dismissTapped() {
result = .dismiss result = .dismiss
NSApp.stopModal() finish()
} }
@objc private func remindTapped() { @objc private func moreTapped() {
panel.makeFirstResponder(nil) // commit field editing pendingAction = .none
minutes = clampedMinutes() let menu = buildMenu()
result = .remind // Pop up just below the chevron.
NSApp.stopModal() 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() { @objc private func presetTapped(_ sender: NSMenuItem) {
minutesField.intValue = stepper.intValue pendingAction = .remind(max(1, min(120, sender.tag)))
} }
@objc private func fieldChanged() { @objc private func customTapped() {
stepper.intValue = Int32(clampedMinutes()) pendingAction = .custom
} }
private func clampedMinutes() -> Int { // MARK: - Menu / custom input
var v = Int(minutesField.intValue)
if v < 1 { v = 1 } private func buildMenu() -> NSMenu {
if v > 120 { v = 120 } let menu = NSMenu()
return v // 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 1120 minute delay.
private func promptCustomMinutes() -> Int? {
let alert = NSAlert()
alert.messageText = "Remind after"
alert.informativeText = "Delay in minutes (1120):"
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 } 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 { private final class CardView: NSView {
override var isFlipped: Bool { true } override var isFlipped: Bool { true }
override func draw(_ dirty: NSRect) { override func draw(_ dirty: NSRect) {