♻️ 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 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 1–120 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 (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
|
|
||||||
|
|
||||||
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 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 }
|
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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user