♻️ 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 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()
}
}
@@ -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 1120 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 (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
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 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 }
}
/// 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) {