♻️ 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:
@@ -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 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) {
|
||||
|
||||
Reference in New Issue
Block a user