From 2dee304e5e6c3c619aad802d1fd702ae5ecb0fb8 Mon Sep 17 00:00:00 2001 From: Bartosz Sroczynski Date: Wed, 4 Nov 2026 13:48:04 +0100 Subject: [PATCH 1/6] feat(appstore): ask for a rating after real use App Store build only. A document window that has been on screen, key and unobstructed for 60 s in one go asks StoreKit for a rating when the reader has settled in: at least 10 opened documents on 3 distinct days, the first one at least 7 days ago, no request yet for this version and none in the last 120 days. At most one request per app run. ReviewPrompt is the pure policy (UserDefaults keys under reviewPrompt.*, garbage reads as nothing recorded); ReviewPromptTrigger is compiled only with -DAPPSTORE, so the GitHub/Homebrew build contains none of it. Co-Authored-By: Claude Opus 5.5 --- QuickMD/QuickMD.xcodeproj/project.pbxproj | 14 ++ QuickMD/QuickMD/MarkdownView.swift | 9 + QuickMD/QuickMD/ReviewPrompt.swift | 187 ++++++++++++++ QuickMD/QuickMD/ReviewPromptTrigger.swift | 113 +++++++++ QuickMD/QuickMDTests/ReviewPromptTests.swift | 249 +++++++++++++++++++ 5 files changed, 572 insertions(+) create mode 100644 QuickMD/QuickMD/ReviewPrompt.swift create mode 100644 QuickMD/QuickMD/ReviewPromptTrigger.swift create mode 100644 QuickMD/QuickMDTests/ReviewPromptTests.swift diff --git a/QuickMD/QuickMD.xcodeproj/project.pbxproj b/QuickMD/QuickMD.xcodeproj/project.pbxproj index d7ad376..339319a 100644 --- a/QuickMD/QuickMD.xcodeproj/project.pbxproj +++ b/QuickMD/QuickMD.xcodeproj/project.pbxproj @@ -32,6 +32,10 @@ A1B2C3D401000220 /* FrontMatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000220 /* FrontMatter.swift */; }; A1B2C3D401000221 /* FrontMatter.swift in Sources (Tests) */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000220 /* FrontMatter.swift */; }; A1B2C3D401000222 /* FrontMatterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000221 /* FrontMatterTests.swift */; }; + A1B2C3D401000230 /* ReviewPrompt.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000230 /* ReviewPrompt.swift */; }; + A1B2C3D401000231 /* ReviewPrompt.swift in Sources (Tests) */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000230 /* ReviewPrompt.swift */; }; + A1B2C3D401000232 /* ReviewPromptTrigger.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000231 /* ReviewPromptTrigger.swift */; }; + A1B2C3D401000233 /* ReviewPromptTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000232 /* ReviewPromptTests.swift */; }; A1B2C3D401000201 /* PrintableTableTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D400000200 /* PrintableTableTests.swift */; }; A1B2C3D4010001C0 /* SelectionRows.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4000001C0 /* SelectionRows.swift */; }; A1B2C3D4010001C1 /* SelectionRows.swift in Sources (Tests) */ = {isa = PBXBuildFile; fileRef = A1B2C3D4000001C0 /* SelectionRows.swift */; }; @@ -219,6 +223,9 @@ A1B2C3D4000001B2 /* TableTextTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableTextTests.swift; sourceTree = ""; }; A1B2C3D400000220 /* FrontMatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrontMatter.swift; sourceTree = ""; }; A1B2C3D400000221 /* FrontMatterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrontMatterTests.swift; sourceTree = ""; }; + A1B2C3D400000230 /* ReviewPrompt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewPrompt.swift; sourceTree = ""; }; + A1B2C3D400000231 /* ReviewPromptTrigger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewPromptTrigger.swift; sourceTree = ""; }; + A1B2C3D400000232 /* ReviewPromptTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewPromptTests.swift; sourceTree = ""; }; A1B2C3D400000200 /* PrintableTableTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrintableTableTests.swift; sourceTree = ""; }; A1B2C3D4000001C0 /* SelectionRows.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectionRows.swift; sourceTree = ""; }; A1B2C3D4000001C1 /* TableSelectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableSelectionTests.swift; sourceTree = ""; }; @@ -332,6 +339,7 @@ A1B2C3D4000000F0 /* HeadingLayoutTests.swift */, A1B2C3D4000001B2 /* TableTextTests.swift */, A1B2C3D400000221 /* FrontMatterTests.swift */, + A1B2C3D400000232 /* ReviewPromptTests.swift */, A1B2C3D400000200 /* PrintableTableTests.swift */, A1B2C3D4000001C1 /* TableSelectionTests.swift */, A1B2C3D4000000C1 /* DocumentSelectionTests.swift */, @@ -362,6 +370,8 @@ A1B2C3D400000069 /* BlockHeightMeasurer.swift */, A1B2C3D4000001B0 /* TableTextBuilder.swift */, A1B2C3D400000220 /* FrontMatter.swift */, + A1B2C3D400000230 /* ReviewPrompt.swift */, + A1B2C3D400000231 /* ReviewPromptTrigger.swift */, A1B2C3D40000006E /* SVGImageDecoder.swift */, A1B2C3D400000080 /* DataImageURI.swift */, A1B2C3D400000081 /* ImageLoader.swift */, @@ -654,6 +664,8 @@ A1B2C3D401000069 /* BlockHeightMeasurer.swift in Sources */, A1B2C3D4010001B0 /* TableTextBuilder.swift in Sources */, A1B2C3D401000220 /* FrontMatter.swift in Sources */, + A1B2C3D401000230 /* ReviewPrompt.swift in Sources */, + A1B2C3D401000232 /* ReviewPromptTrigger.swift in Sources */, A1B2C3D4010001B2 /* TableTextView.swift in Sources */, A1B2C3D40100006C /* VirtualBlockList.swift in Sources */, A1B2C3D40100006D /* SVGBlockView.swift in Sources */, @@ -706,6 +718,7 @@ A1B2C3D40100006B /* BlockHeightMeasurer.swift in Sources (Tests) */, A1B2C3D4010001B1 /* TableTextBuilder.swift in Sources (Tests) */, A1B2C3D401000221 /* FrontMatter.swift in Sources (Tests) */, + A1B2C3D401000231 /* ReviewPrompt.swift in Sources (Tests) */, A1B2C3D40100006F /* SVGImageDecoder.swift in Sources (Tests) */, A1B2C3D401000081 /* DataImageURI.swift in Sources (Tests) */, A1B2C3D401000083 /* ImageLoader.swift in Sources (Tests) */, @@ -737,6 +750,7 @@ A1B2C3D4010000F0 /* HeadingLayoutTests.swift in Sources */, A1B2C3D4010001B3 /* TableTextTests.swift in Sources */, A1B2C3D401000222 /* FrontMatterTests.swift in Sources */, + A1B2C3D401000233 /* ReviewPromptTests.swift in Sources */, A1B2C3D401000201 /* PrintableTableTests.swift in Sources */, A1B2C3D4010001C2 /* TableSelectionTests.swift in Sources */, ); diff --git a/QuickMD/QuickMD/MarkdownView.swift b/QuickMD/QuickMD/MarkdownView.swift index bac246a..be2d89c 100644 --- a/QuickMD/QuickMD/MarkdownView.swift +++ b/QuickMD/QuickMD/MarkdownView.swift @@ -884,6 +884,15 @@ struct MarkdownView: View { fileWatcher = nil releaseEditSession() } + #if APPSTORE + // Rating prompt (App Store build only): counts this window's open once + // its first parse landed (`contentVersion` only grows — reloads, zoom + // and theme changes don't count again) and may ask after 60 s of + // display. Not while Source Edit is up. + .modifier(ReviewPromptTrigger(isRendered: contentVersion > 0, + isEditing: editSession.isActive, + window: hostWindow)) + #endif } // MARK: - Auto-Reload & External Editor diff --git a/QuickMD/QuickMD/ReviewPrompt.swift b/QuickMD/QuickMD/ReviewPrompt.swift new file mode 100644 index 0000000..d1844eb --- /dev/null +++ b/QuickMD/QuickMD/ReviewPrompt.swift @@ -0,0 +1,187 @@ +import Foundation + +// MARK: - Review prompt policy (App Store build) + +/// When to ask for an App Store rating: only after real, repeated use — never +/// on a first launch, never twice for one version, never more often than every +/// 120 days. Apple throttles `requestReview` on its own (at most three prompts +/// a year), but a prompt it does show at the wrong moment costs goodwill, so +/// the app asks only when a reader has clearly settled in. +/// +/// Pure policy: no StoreKit, no SwiftUI, no clock of its own (`now` is always +/// passed in) and an injectable `UserDefaults`, so every rule is unit-tested. +/// It compiles into both builds, but only the App Store build calls it +/// (`ReviewPromptTrigger`, `#if APPSTORE`); the GitHub/Homebrew build never +/// touches these keys. +/// +/// Anything unreadable in the defaults (wrong type, a hand-edited value, a +/// date from the future) reads as "nothing recorded yet": it can delay a +/// prompt, never cause one or crash. +struct ReviewPrompt { + + /// Every key lives under this prefix. + static let keyPrefix = "reviewPrompt." + + enum Keys { + /// Int — documents that finished their first render, ever. + static let openCount = ReviewPrompt.keyPrefix + "openCount" + /// [String] — local calendar days (`yyyy-MM-dd`) with at least one + /// open, newest `maximumStoredDays` only. + static let openDays = ReviewPrompt.keyPrefix + "openDays" + /// Date — the first recorded open. + static let firstOpen = ReviewPrompt.keyPrefix + "firstOpen" + /// Date — the last time we asked StoreKit for a review. + static let lastRequest = ReviewPrompt.keyPrefix + "lastRequest" + /// String — `CFBundleShortVersionString` of that request. + static let lastRequestVersion = ReviewPrompt.keyPrefix + "lastRequestVersion" + } + + static let minimumOpens = 10 + static let minimumDistinctDays = 3 + static let minimumDaysSinceFirstOpen = 7 + static let minimumDaysBetweenRequests = 120 + /// The day set only has to answer "at least 3 days"; 30 keeps it bounded + /// for a reader who opens documents every day for years. + static let maximumStoredDays = 30 + + private static let secondsPerDay: TimeInterval = 86_400 + + let defaults: UserDefaults + /// Decides what "a day" is for the distinct-days rule (the user's local + /// days in the app; a fixed time zone in tests). + let calendar: Calendar + + init(defaults: UserDefaults = .standard, calendar: Calendar = .current) { + self.defaults = defaults + self.calendar = calendar + } + + // MARK: Recording + + /// One document window/tab finished its first successful render. The + /// caller guarantees once per window — reloads, zoom, theme changes and + /// Source Edit saves are not new opens. + func recordDocumentOpened(now: Date) { + let count = openCount + defaults.set(count == Int.max ? count : count + 1, forKey: Keys.openCount) + + // Sorted ascending; the key format sorts chronologically, so the cap + // keeps the newest days even if the clock was once set back. + var days = openDays + days.insert(dayKey(for: now)) + defaults.set(Array(days.sorted().suffix(Self.maximumStoredDays)), forKey: Keys.openDays) + + // A missing, unreadable or future first-open date (clock set forward + // once) would block the age rule for good: restart it from today. + let firstIsUsable = firstOpen.map { $0 <= now } ?? false + if !firstIsUsable { + defaults.set(now, forKey: Keys.firstOpen) + } + } + + // MARK: Deciding + + /// True only when ALL hold: ≥ 10 opens, opens on ≥ 3 distinct days, ≥ 7 + /// days since the first open, no request yet for `appVersion`, and ≥ 120 + /// days since the last request (if there was one). + func shouldRequest(now: Date, appVersion: String) -> Bool { + guard openCount >= Self.minimumOpens else { return false } + guard openDays.count >= Self.minimumDistinctDays else { return false } + guard let first = firstOpen, + now.timeIntervalSince(first) >= Double(Self.minimumDaysSinceFirstOpen) * Self.secondsPerDay + else { return false } + if let version = lastRequestVersion, version == appVersion { return false } + if let last = lastRequest, + now.timeIntervalSince(last) < Double(Self.minimumDaysBetweenRequests) * Self.secondsPerDay { + // Also true for a request date in the future: wait, don't guess. + return false + } + return true + } + + /// Called right after the app asked StoreKit — whether or not the system + /// actually showed the prompt (it never says). + func markRequested(now: Date, appVersion: String) { + defaults.set(now, forKey: Keys.lastRequest) + defaults.set(appVersion, forKey: Keys.lastRequestVersion) + } + + // MARK: Stored values, garbage-tolerant + + var openCount: Int { + guard let count = defaults.object(forKey: Keys.openCount) as? Int, count > 0 else { return 0 } + return count + } + + /// Valid day keys only — a hand-written "x" must not count as a day. + var openDays: Set { + guard let stored = defaults.object(forKey: Keys.openDays) as? [Any] else { return [] } + return Set(stored.compactMap { $0 as? String }.filter(Self.isDayKey)) + } + + var firstOpen: Date? { Self.validDate(defaults.object(forKey: Keys.firstOpen)) } + var lastRequest: Date? { Self.validDate(defaults.object(forKey: Keys.lastRequest)) } + + var lastRequestVersion: String? { + guard let version = defaults.object(forKey: Keys.lastRequestVersion) as? String, + !version.isEmpty else { return nil } + return version + } + + // MARK: Helpers + + /// `yyyy-MM-dd` in `calendar`'s time zone. Built from components, not a + /// DateFormatter: no locale (or non-Gregorian era) can change the shape. + func dayKey(for date: Date) -> String { + let parts = calendar.dateComponents([.year, .month, .day], from: date) + return String(format: "%04d-%02d-%02d", parts.year ?? 0, parts.month ?? 0, parts.day ?? 0) + } + + static func isDayKey(_ string: String) -> Bool { + let bytes = Array(string.utf8) + guard bytes.count == 10 else { return false } + for (index, byte) in bytes.enumerated() { + if index == 4 || index == 7 { + guard byte == UInt8(ascii: "-") else { return false } + } else { + guard byte >= UInt8(ascii: "0"), byte <= UInt8(ascii: "9") else { return false } + } + } + return true + } + + private static func validDate(_ value: Any?) -> Date? { + guard let date = value as? Date, date.timeIntervalSinceReferenceDate.isFinite else { return nil } + return date + } +} + +// MARK: - Continuous display countdown + +/// "The window has been on screen for 60 s in one go." Fed by a periodic tick +/// with whether the window is displayed right now; any tick that is not +/// resets the run. Pure so the rule is tested without timers. +struct ReviewPromptCountdown { + let required: TimeInterval + /// Start of the current uninterrupted run; nil = not displayed. + private(set) var displayedSince: Date? + + init(required: TimeInterval) { + self.required = required + } + + /// Returns true once `required` seconds of uninterrupted display have + /// passed by `now`. + mutating func advance(isDisplayed: Bool, now: Date) -> Bool { + guard isDisplayed else { + displayedSince = nil + return false + } + // The clock went back: count from here rather than never. + guard let since = displayedSince, since <= now else { + displayedSince = now + return false + } + return now.timeIntervalSince(since) >= required + } +} diff --git a/QuickMD/QuickMD/ReviewPromptTrigger.swift b/QuickMD/QuickMD/ReviewPromptTrigger.swift new file mode 100644 index 0000000..3ca2490 --- /dev/null +++ b/QuickMD/QuickMD/ReviewPromptTrigger.swift @@ -0,0 +1,113 @@ +// App Store build only: the GitHub/Homebrew build has no rating prompt, and +// none of this file is compiled into it (`OTHER_SWIFT_FLAGS="-DAPPSTORE"`). +#if APPSTORE +import SwiftUI +import AppKit +import StoreKit +#if DEBUG +import os + +private let reviewLog = Logger(subsystem: "pl.falami.studio.QuickMD", category: "ReviewPrompt") +#endif + +/// One rating request per app run, across every window and tab. +@MainActor +enum ReviewPromptSession { + static var hasRequested = false +} + +/// Attached to each document window's `MarkdownView`. Records the open once, +/// when the document first rendered, and — if `ReviewPrompt` says the reader +/// has settled in — asks StoreKit for a rating after 60 s of uninterrupted +/// display of this window. +/// +/// "Displayed" is checked once a second: the window is visible, unoccluded, +/// key (the reader is looking at THIS tab, the app is frontmost) and has no +/// sheet or app-modal panel over it. A tick that fails any of it restarts the +/// 60 s. Source Edit stops it (the task id changes and the task returns while +/// editing; leaving starts a fresh 60 s), closing the window cancels it with +/// the view's task. +/// +/// The policy is evaluated when the countdown arms AND when it completes: in +/// the usual case (thresholds not met, or this version already asked) no +/// timer runs at all. +struct ReviewPromptTrigger: ViewModifier { + /// The document finished its first render (`contentVersion > 0`). Never + /// goes back to false, so reloads, zoom and theme changes do not re-arm. + let isRendered: Bool + let isEditing: Bool + /// The hosting window (`MarkdownView.hostWindow`), nil until attached. + let window: NSWindow? + + static let requiredDisplay: TimeInterval = 60 + private static let tick: UInt64 = 1_000_000_000 + + @Environment(\.requestReview) private var requestReview + /// Per window: the open is recorded once even if the task re-runs + /// (Source Edit toggles, the window object arriving, a re-appear). + @State private var didRecordOpen = false + + private struct Arming: Equatable { + let isRendered: Bool + let isEditing: Bool + let window: ObjectIdentifier? + } + + func body(content: Content) -> some View { + content + .task(id: Arming(isRendered: isRendered, isEditing: isEditing, + window: window.map(ObjectIdentifier.init))) { + await run() + } + } + + /// `@MainActor` explicitly: on the CI SDK a View's helpers are + /// nonisolated, and this touches NSWindow / NSApp / `requestReview`. + @MainActor + private func run() async { + guard isRendered else { return } + let policy = ReviewPrompt() + if !didRecordOpen { + didRecordOpen = true + policy.recordDocumentOpened(now: Date()) + } + guard !isEditing, let window, Self.canRequest(policy) else { return } + + var countdown = ReviewPromptCountdown(required: Self.requiredDisplay) + _ = countdown.advance(isDisplayed: Self.isDisplayed(window), now: Date()) + while true { + try? await Task.sleep(nanoseconds: Self.tick) + guard !Task.isCancelled else { return } + guard countdown.advance(isDisplayed: Self.isDisplayed(window), now: Date()) else { continue } + // Another window may have asked meanwhile, or the policy changed. + guard Self.canRequest(policy) else { return } + ReviewPromptSession.hasRequested = true + #if DEBUG + reviewLog.debug("requestReview for \(Self.appVersion, privacy: .public)") + #endif + requestReview() + policy.markRequested(now: Date(), appVersion: Self.appVersion) + return + } + } + + @MainActor + private static func canRequest(_ policy: ReviewPrompt) -> Bool { + !ReviewPromptSession.hasRequested && policy.shouldRequest(now: Date(), appVersion: appVersion) + } + + @MainActor + private static func isDisplayed(_ window: NSWindow) -> Bool { + window.isVisible + && !window.isMiniaturized + && window.occlusionState.contains(.visible) + && window.isKeyWindow + && window.attachedSheet == nil + && NSApp.modalWindow == nil + } + + private static var appVersion: String { + Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0" + } +} +#endif diff --git a/QuickMD/QuickMDTests/ReviewPromptTests.swift b/QuickMD/QuickMDTests/ReviewPromptTests.swift new file mode 100644 index 0000000..6edddd0 --- /dev/null +++ b/QuickMD/QuickMDTests/ReviewPromptTests.swift @@ -0,0 +1,249 @@ +import XCTest +import Foundation + +/// The App Store rating prompt's policy (`ReviewPrompt`) and its continuous +/// display countdown (`ReviewPromptCountdown`). Every date is built from a +/// fixed base in UTC, so nothing depends on the machine's clock, time zone or +/// speed. +final class ReviewPromptTests: XCTestCase { + + private var suiteName: String! + private var defaults: UserDefaults! + private var policy: ReviewPrompt! + private var calendar: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "UTC")! + return calendar + }() + + /// 2026-01-01 10:00 UTC. + private let base = Date(timeIntervalSince1970: 1_767_261_600) + private let version = "1.15.0" + + /// Per-test directory that holds the suite's plist. + private var suiteDirectory: URL! + + /// The suite name is an ABSOLUTE PATH (CFPreferences then stores the + /// domain at `.plist`), inside a per-test temporary directory. + /// A plain suite name leaks `~/Library/Preferences/.plist`: + /// `removePersistentDomain` makes cfprefsd write an EMPTY plist there + /// seconds later (measured: ~20 s after the test process ended), so + /// neither deleting the file in tearDown nor `synchronize()` first wins + /// the race. A late write here lands in the temporary directory. + override func setUpWithError() throws { + try super.setUpWithError() + suiteDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("QuickMD-ReviewPromptTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: suiteDirectory, withIntermediateDirectories: true) + suiteName = suiteDirectory.appendingPathComponent("defaults").path + defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + policy = ReviewPrompt(defaults: defaults, calendar: calendar) + } + + override func tearDownWithError() throws { + defaults.removePersistentDomain(forName: suiteName) + try? FileManager.default.removeItem(at: suiteDirectory) + defaults = nil + policy = nil + try super.tearDownWithError() + } + + private func day(_ n: Int, hour: Int = 0) -> Date { + base.addingTimeInterval(TimeInterval(n) * 86_400 + TimeInterval(hour) * 3_600) + } + + /// `count` opens spread over days 0, 1 and 2 (the rest on day 2). + private func recordQualifyingOpens(_ count: Int) { + for i in 0.. Date: Wed, 4 Nov 2026 13:59:02 +0100 Subject: [PATCH 2/6] fix(appstore): rating prompt counts real reading time only The 60 s run is measured on systemUptime (no progress in sleep) and restarts when two ticks are more than 3 s apart (sleep, App Nap, a stalled main thread). A window that is not key does not tick at all: it waits for didBecomeKeyNotification and starts a fresh run; every tick stops once any window has asked. At the moment of asking the user must have produced input within the last 60 s, otherwise the prompt keeps waiting. Co-Authored-By: Claude Opus 5.5 --- QuickMD/QuickMD/ReviewPrompt.swift | 55 +++++++++++--- QuickMD/QuickMD/ReviewPromptTrigger.swift | 79 +++++++++++++++----- QuickMD/QuickMDTests/ReviewPromptTests.swift | 76 +++++++++++++++---- 3 files changed, 167 insertions(+), 43 deletions(-) diff --git a/QuickMD/QuickMD/ReviewPrompt.swift b/QuickMD/QuickMD/ReviewPrompt.swift index d1844eb..985c2bf 100644 --- a/QuickMD/QuickMD/ReviewPrompt.swift +++ b/QuickMD/QuickMD/ReviewPrompt.swift @@ -43,6 +43,10 @@ struct ReviewPrompt { /// The day set only has to answer "at least 3 days"; 30 keeps it bounded /// for a reader who opens documents every day for years. static let maximumStoredDays = 30 + /// At the moment of asking, the reader must have touched a key, the mouse + /// or the trackpad within this many seconds: a window left open on an + /// unattended Mac is not reading. + static let maximumIdleAtRequest: TimeInterval = 60 private static let secondsPerDay: TimeInterval = 86_400 @@ -106,6 +110,15 @@ struct ReviewPrompt { defaults.set(appVersion, forKey: Keys.lastRequestVersion) } + /// True when the last user input (seconds ago, from the system's event + /// source) is recent enough to ask. Negative / NaN / infinite = unknown = + /// not active: an unreadable answer must not cause a prompt. + static func isUserActive(secondsSinceLastInput: TimeInterval) -> Bool { + secondsSinceLastInput.isFinite + && secondsSinceLastInput >= 0 + && secondsSinceLastInput < maximumIdleAtRequest + } + // MARK: Stored values, garbage-tolerant var openCount: Int { @@ -134,7 +147,7 @@ struct ReviewPrompt { /// DateFormatter: no locale (or non-Gregorian era) can change the shape. func dayKey(for date: Date) -> String { let parts = calendar.dateComponents([.year, .month, .day], from: date) - return String(format: "%04d-%02d-%02d", parts.year ?? 0, parts.month ?? 0, parts.day ?? 0) + return String(format: "%04ld-%02ld-%02ld", parts.year ?? 0, parts.month ?? 0, parts.day ?? 0) } static func isDayKey(_ string: String) -> Bool { @@ -159,29 +172,49 @@ struct ReviewPrompt { // MARK: - Continuous display countdown /// "The window has been on screen for 60 s in one go." Fed by a periodic tick -/// with whether the window is displayed right now; any tick that is not -/// resets the run. Pure so the rule is tested without timers. +/// (about once a second) with whether the window is displayed right now; any +/// tick that is not resets the run. Pure so the rule is tested without timers. +/// +/// Times are MONOTONIC seconds (`ProcessInfo.systemUptime`: it does not +/// advance while the Mac sleeps, and no clock change moves it). A gap of more +/// than `maximumGap` between two ticks also resets the run: the ticks stopped +/// (sleep, App Nap, a stalled main thread), so nobody can say the window was +/// being read in between. struct ReviewPromptCountdown { let required: TimeInterval + let maximumGap: TimeInterval /// Start of the current uninterrupted run; nil = not displayed. - private(set) var displayedSince: Date? + private(set) var displayedSince: TimeInterval? + /// The previous displayed tick of this run. + private(set) var lastTick: TimeInterval? - init(required: TimeInterval) { + init(required: TimeInterval, maximumGap: TimeInterval = 3) { self.required = required + self.maximumGap = maximumGap } /// Returns true once `required` seconds of uninterrupted display have - /// passed by `now`. - mutating func advance(isDisplayed: Bool, now: Date) -> Bool { + /// passed by `now`. Keeps returning true on later displayed ticks of the + /// same run (the caller may still be waiting for other conditions). + mutating func advance(isDisplayed: Bool, now: TimeInterval) -> Bool { guard isDisplayed else { - displayedSince = nil + reset() return false } - // The clock went back: count from here rather than never. - guard let since = displayedSince, since <= now else { + guard let since = displayedSince, let last = lastTick, + now >= last, now - last <= maximumGap else { displayedSince = now + lastTick = now return false } - return now.timeIntervalSince(since) >= required + lastTick = now + return now - since >= required + } + + /// Ends the current run (the window stopped being key); the next + /// displayed tick starts a new one from zero. + mutating func reset() { + displayedSince = nil + lastTick = nil } } diff --git a/QuickMD/QuickMD/ReviewPromptTrigger.swift b/QuickMD/QuickMD/ReviewPromptTrigger.swift index 3ca2490..e081eac 100644 --- a/QuickMD/QuickMD/ReviewPromptTrigger.swift +++ b/QuickMD/QuickMD/ReviewPromptTrigger.swift @@ -21,12 +21,16 @@ enum ReviewPromptSession { /// has settled in — asks StoreKit for a rating after 60 s of uninterrupted /// display of this window. /// -/// "Displayed" is checked once a second: the window is visible, unoccluded, -/// key (the reader is looking at THIS tab, the app is frontmost) and has no -/// sheet or app-modal panel over it. A tick that fails any of it restarts the -/// 60 s. Source Edit stops it (the task id changes and the task returns while -/// editing; leaving starts a fresh 60 s), closing the window cancels it with -/// the view's task. +/// Ticks once a second ONLY while the window is key (the reader is looking at +/// THIS tab, the app is frontmost); otherwise it waits for +/// `didBecomeKeyNotification` and costs nothing. "Displayed" per tick: visible, +/// unoccluded, key, no sheet or app-modal panel. A tick that fails it, losing +/// key, or a gap of more than 3 s between ticks (sleep, App Nap, a stalled +/// main thread — measured on the monotonic `systemUptime`) restarts the 60 s. +/// At 60 s it also needs user input within the last 60 s; an idle reader keeps +/// it waiting. Source Edit stops it (the task id changes and the task returns +/// while editing; leaving starts a fresh 60 s), closing the window cancels it +/// with the view's task. /// /// The policy is evaluated when the countdown arms AND when it completes: in /// the usual case (thresholds not met, or this version already asked) no @@ -74,28 +78,65 @@ struct ReviewPromptTrigger: ViewModifier { guard !isEditing, let window, Self.canRequest(policy) else { return } var countdown = ReviewPromptCountdown(required: Self.requiredDisplay) - _ = countdown.advance(isDisplayed: Self.isDisplayed(window), now: Date()) - while true { + while !Task.isCancelled { + // Another window asked: stop for good, no further ticks. + guard !ReviewPromptSession.hasRequested else { return } + // Not key (background tab, another app or window in front, a + // sheet): no ticking at all — sleep until AppKit says the window + // is key again, then count a fresh 60 s. + if !window.isKeyWindow { + countdown.reset() + guard await Self.waitUntilKey(window) else { return } + continue + } + let displayedLongEnough = countdown.advance(isDisplayed: Self.isDisplayed(window), + now: ProcessInfo.processInfo.systemUptime) + if displayedLongEnough { + // Another window may have asked meanwhile, or the policy changed. + guard Self.canRequest(policy) else { return } + // An unattended screen is not reading: keep waiting (the run + // stays complete) until the reader touches the Mac again. + if Self.userIsActive { + ReviewPromptSession.hasRequested = true + #if DEBUG + reviewLog.debug("requestReview for \(Self.appVersion, privacy: .public)") + #endif + requestReview() + policy.markRequested(now: Date(), appVersion: Self.appVersion) + return + } + } try? await Task.sleep(nanoseconds: Self.tick) - guard !Task.isCancelled else { return } - guard countdown.advance(isDisplayed: Self.isDisplayed(window), now: Date()) else { continue } - // Another window may have asked meanwhile, or the policy changed. - guard Self.canRequest(policy) else { return } - ReviewPromptSession.hasRequested = true - #if DEBUG - reviewLog.debug("requestReview for \(Self.appVersion, privacy: .public)") - #endif - requestReview() - policy.markRequested(now: Date(), appVersion: Self.appVersion) - return } } + /// Suspends until `window` becomes key; false when the task was cancelled + /// (window closed, view gone, Source Edit entered). The notification + /// sequence is created BEFORE the key check, so a window that becomes key + /// in between is not missed. + @MainActor + private static func waitUntilKey(_ window: NSWindow) async -> Bool { + let becameKey = NotificationCenter.default.notifications(named: NSWindow.didBecomeKeyNotification, + object: window) + var iterator = becameKey.makeAsyncIterator() + if window.isKeyWindow { return !Task.isCancelled } + let arrived = await iterator.next() != nil + return arrived && !Task.isCancelled + } + @MainActor private static func canRequest(_ policy: ReviewPrompt) -> Bool { !ReviewPromptSession.hasRequested && policy.shouldRequest(now: Date(), appVersion: appVersion) } + /// Any keyboard / mouse / trackpad event within `maximumIdleAtRequest`. + /// `~0` is `kCGAnyInputEventType`; an unconvertible type reads as idle. + private static var userIsActive: Bool { + guard let anyInput = CGEventType(rawValue: ~0) else { return false } + let idle = CGEventSource.secondsSinceLastEventType(.combinedSessionState, eventType: anyInput) + return ReviewPrompt.isUserActive(secondsSinceLastInput: idle) + } + @MainActor private static func isDisplayed(_ window: NSWindow) -> Bool { window.isVisible diff --git a/QuickMD/QuickMDTests/ReviewPromptTests.swift b/QuickMD/QuickMDTests/ReviewPromptTests.swift index 6edddd0..c59a9b4 100644 --- a/QuickMD/QuickMDTests/ReviewPromptTests.swift +++ b/QuickMD/QuickMDTests/ReviewPromptTests.swift @@ -222,28 +222,78 @@ final class ReviewPromptTests: XCTestCase { // MARK: - Continuous display countdown + /// Feeds displayed ticks one second apart from `start` through `end` + /// (inclusive) and returns the last result. + @discardableResult + private func tick(_ countdown: inout ReviewPromptCountdown, + from start: TimeInterval, through end: TimeInterval) -> Bool { + var result = false + var now = start + while now <= end { + result = countdown.advance(isDisplayed: true, now: now) + now += 1 + } + return result + } + + /// Monotonic seconds, like `systemUptime`. + private let uptime: TimeInterval = 5_000 + func testCountdownNeedsUninterruptedDisplay() { var countdown = ReviewPromptCountdown(required: 60) - XCTAssertFalse(countdown.advance(isDisplayed: true, now: base)) - XCTAssertFalse(countdown.advance(isDisplayed: true, now: base.addingTimeInterval(59))) - XCTAssertTrue(countdown.advance(isDisplayed: true, now: base.addingTimeInterval(60))) + XCTAssertFalse(tick(&countdown, from: uptime, through: uptime + 59)) + XCTAssertTrue(countdown.advance(isDisplayed: true, now: uptime + 60)) + XCTAssertTrue(countdown.advance(isDisplayed: true, now: uptime + 61), "a complete run stays complete") } func testCountdownRestartsAfterAnInterruption() { var countdown = ReviewPromptCountdown(required: 60) - _ = countdown.advance(isDisplayed: true, now: base) - XCTAssertFalse(countdown.advance(isDisplayed: false, now: base.addingTimeInterval(50))) + tick(&countdown, from: uptime, through: uptime + 50) + XCTAssertFalse(countdown.advance(isDisplayed: false, now: uptime + 51)) XCTAssertNil(countdown.displayedSince) - XCTAssertFalse(countdown.advance(isDisplayed: true, now: base.addingTimeInterval(51))) - XCTAssertFalse(countdown.advance(isDisplayed: true, now: base.addingTimeInterval(110))) - XCTAssertTrue(countdown.advance(isDisplayed: true, now: base.addingTimeInterval(111))) + XCTAssertFalse(tick(&countdown, from: uptime + 52, through: uptime + 111)) + XCTAssertTrue(countdown.advance(isDisplayed: true, now: uptime + 112)) } - func testCountdownSurvivesTheClockGoingBack() { + func testCountdownRestartsAfterAGapBetweenTicks() { + var countdown = ReviewPromptCountdown(required: 60, maximumGap: 3) + tick(&countdown, from: uptime, through: uptime + 40) + // 3 s since the previous tick is still continuous… + XCTAssertFalse(countdown.advance(isDisplayed: true, now: uptime + 43)) + XCTAssertEqual(countdown.displayedSince, uptime) + // …4 s is not (sleep, App Nap, a stalled main thread): a new run. + XCTAssertFalse(countdown.advance(isDisplayed: true, now: uptime + 47)) + XCTAssertEqual(countdown.displayedSince, uptime + 47) + XCTAssertFalse(tick(&countdown, from: uptime + 48, through: uptime + 106)) + XCTAssertTrue(countdown.advance(isDisplayed: true, now: uptime + 107)) + } + + func testCountdownResetStartsFromZero() { var countdown = ReviewPromptCountdown(required: 60) - _ = countdown.advance(isDisplayed: true, now: base) - XCTAssertFalse(countdown.advance(isDisplayed: true, now: base.addingTimeInterval(-3_600))) - XCTAssertEqual(countdown.displayedSince, base.addingTimeInterval(-3_600)) - XCTAssertTrue(countdown.advance(isDisplayed: true, now: base.addingTimeInterval(-3_540))) + tick(&countdown, from: uptime, through: uptime + 59) + countdown.reset() + XCTAssertNil(countdown.displayedSince) + XCTAssertNil(countdown.lastTick) + XCTAssertFalse(countdown.advance(isDisplayed: true, now: uptime + 60)) + } + + func testCountdownSurvivesTimeGoingBack() { + var countdown = ReviewPromptCountdown(required: 60) + tick(&countdown, from: uptime, through: uptime + 30) + XCTAssertFalse(countdown.advance(isDisplayed: true, now: uptime - 100)) + XCTAssertEqual(countdown.displayedSince, uptime - 100) + XCTAssertTrue(tick(&countdown, from: uptime - 99, through: uptime - 40)) + } + + // MARK: - Idle check at the moment of asking + + func testUserActivityWindow() { + XCTAssertTrue(ReviewPrompt.isUserActive(secondsSinceLastInput: 0)) + XCTAssertTrue(ReviewPrompt.isUserActive(secondsSinceLastInput: 59.9)) + XCTAssertFalse(ReviewPrompt.isUserActive(secondsSinceLastInput: 60)) + XCTAssertFalse(ReviewPrompt.isUserActive(secondsSinceLastInput: 3_600)) + XCTAssertFalse(ReviewPrompt.isUserActive(secondsSinceLastInput: -1)) + XCTAssertFalse(ReviewPrompt.isUserActive(secondsSinceLastInput: .nan)) + XCTAssertFalse(ReviewPrompt.isUserActive(secondsSinceLastInput: .infinity)) } } From 185c99d948a3fde876a7b815a161f78ce6b8fa90 Mon Sep 17 00:00:00 2001 From: Bartosz Sroczynski Date: Wed, 4 Nov 2026 14:01:18 +0100 Subject: [PATCH 3/6] fix(parser): no blank line drawn above or below a block A text run kept the blank source lines at its ends, and the renderer turns every blank line into empty paragraphs: `## H\n\ntext` drew a whole empty line above "text" that `## H\ntext` did not, and a list followed by a blank line and a heading an extra one below it. flushTextBuffer now trims blank (and whitespace-only) lines from both ends of every chunk; blank lines between content lines are untouched. sourceLine was already the chunk's first non-blank line and is unchanged; the measurer lays out the trimmed string, so view and row height still agree. Co-Authored-By: Claude Opus 5.5 --- QuickMD/QuickMD/MarkdownBlockParser.swift | 28 +++-- .../BlockHeightMeasurerTests.swift | 15 +++ QuickMD/QuickMDTests/ParserTests.swift | 101 ++++++++++++++++++ 3 files changed, 136 insertions(+), 8 deletions(-) diff --git a/QuickMD/QuickMD/MarkdownBlockParser.swift b/QuickMD/QuickMD/MarkdownBlockParser.swift index 8ad9ab6..850d4c4 100644 --- a/QuickMD/QuickMD/MarkdownBlockParser.swift +++ b/QuickMD/QuickMD/MarkdownBlockParser.swift @@ -846,7 +846,7 @@ struct MarkdownBlockParser: Sendable { // blockquotes, a heading and a code fence, …) used to flush an EMPTY // text block that rendered as a ~42 pt gap. Drop it: block spacing is // the stack's job, not the parser's. - guard buffer.contains(where: { !$0.line.trimmingCharacters(in: .whitespaces).isEmpty }) else { + guard buffer.contains(where: { !Self.isBlank($0.line) }) else { buffer.removeAll() return } @@ -874,16 +874,28 @@ struct MarkdownBlockParser: Sendable { } let chunk = buffer[chunkStart.. [(text: String, line: Int)] { + parse(markdown).compactMap { block in + plainText(block).map { ($0, block.sourceLine) } + } + } + + /// The renderer's string for `markdown` — what a text block holding exactly + /// those lines shows. + private func rendered(_ markdown: String) -> String { + String(MarkdownRenderer(theme: MarkdownTheme.cached(for: .light)).render(markdown).characters) + } + + func testBlankLineAfterHeadingAddsNoEmptyParagraph() { + let loose = textBlocks("## Para\n\nText after blank.") + let tight = textBlocks("## Para\nText after blank.") + XCTAssertEqual(loose.map(\.text), tight.map(\.text)) + XCTAssertEqual(loose.map(\.text), [rendered("Text after blank.")]) + XCTAssertEqual(loose.map(\.line), [2], "the paragraph's own line, not the blank above it") + XCTAssertEqual(tight.map(\.line), [1]) + } + + func testBlankLinesAroundAListAddNoEmptyParagraphs() { + let loose = textBlocks("## Tight\n\n- one\n- two\n\n\n## Next") + let tight = textBlocks("## Tight\n- one\n- two\n## Next") + XCTAssertEqual(loose.map(\.text), tight.map(\.text)) + XCTAssertEqual(loose.map(\.text), [rendered("- one\n- two")]) + XCTAssertEqual(loose.map(\.line), [2]) + } + + func testTrailingBlankLinesBeforeTheNextBlockAreDropped() { + // Whitespace-only lines are blank too; the document ends in blanks. + for markdown in ["Text.\n\n## Next", "Text.\n \n\t\n```\ncode\n```", "Text.\n\n\n"] { + let blocks = textBlocks(markdown) + XCTAssertEqual(blocks.map(\.text), [rendered("Text.")], markdown.debugDescription) + XCTAssertEqual(blocks.map(\.line), [0], markdown.debugDescription) + } + } + + func testLeadingWhitespaceOnlyLinesAreDropped() { + let blocks = textBlocks("> quote\n \n\n\t\nText.") + XCTAssertEqual(blocks.map(\.text), [rendered("Text.")]) + XCTAssertEqual(blocks.map(\.line), [4]) + } + + func testBlankLinesInsideATextBlockStay() { + // Paragraph separation inside the block is exactly what it was. + let inner = "First paragraph.\n\nSecond paragraph.\n\n\n- item\n\nLast." + let blocks = textBlocks("# H\n\n" + inner + "\n\n# Next") + XCTAssertEqual(blocks.map(\.text), [rendered(inner)]) + XCTAssertEqual(blocks.map(\.line), [2]) + } + + func testCRLFInputTrimsLikeLF() { + let lf = "## X\n\nText.\n\n- a\n- b\n\n## Y\n" + let crlf = lf.replacingOccurrences(of: "\n", with: "\r\n") + let normalized = textBlocks(MarkdownDocument.normalizeLineEndings(crlf)) + let expected = textBlocks(lf) + XCTAssertEqual(normalized.map(\.text), expected.map(\.text)) + XCTAssertEqual(normalized.map(\.line), expected.map(\.line)) + XCTAssertEqual(expected.map(\.text), [rendered("Text.\n\n- a\n- b")]) + } + + func testDocumentOfOnlyBlankLinesHasNoBlocks() { + for markdown in ["", "\n", "\n\n\n", " \n\t\n ", + MarkdownDocument.normalizeLineEndings("\r\n\r\n")] { + XCTAssertTrue(parse(markdown).isEmpty, markdown.debugDescription) + } + } + + func testChunksNeitherStartNorEndWithBlankLines() { + // 80 paragraphs separated by runs of 1–3 blank lines (some + // whitespace-only): the run is cut into several chunks, the cuts land + // on blank lines, and no chunk may carry them at either end. + var lines: [String] = [] + for n in 1...80 { + lines.append("paragraph \(n)") + for blank in 0..<(n % 3 + 1) { lines.append(blank == 1 ? " " : "") } + } + let blocks = textBlocks("# H\n" + lines.joined(separator: "\n")) + XCTAssertGreaterThan(blocks.count, 1, "the run must be chunked") + for block in blocks { + XCTAssertFalse(block.text.hasPrefix("\n"), "chunk at line \(block.line) starts blank") + XCTAssertFalse(block.text.hasSuffix("\n\n"), "chunk at line \(block.line) ends blank") + XCTAssertTrue(block.text.hasPrefix("paragraph "), "line \(block.line)") + XCTAssertEqual(lines[block.line - 1], block.text.components(separatedBy: "\n")[0], + "sourceLine is the first line the chunk renders") + } + // Every paragraph is still rendered exactly once. + let paragraphs = blocks.flatMap { $0.text.components(separatedBy: "\n") } + .filter { $0.hasPrefix("paragraph ") } + XCTAssertEqual(paragraphs, (1...80).map { "paragraph \($0)" }) + } + func testHeadingStoredSourceLineMatchesAssociatedValue() { // 0 `[ref]: …` (filtered), 1 blank, 2 `# ATX`, 3 `body`, 4 blank, // 5 `Setext`, 6 `------` From 6a1646671987a0d7e4bd871128b35f70995078e2 Mon Sep 17 00:00:00 2001 From: Bartosz Sroczynski Date: Wed, 4 Nov 2026 14:01:42 +0100 Subject: [PATCH 4/6] fix(math): show the source when a formula cannot be typeset A display formula SwiftMath refuses (e.g. `\not\le`: "Unsupported \not\le combination") drew an empty row. Once per parse, on the main actor and before layout, such a `.mathBlock` becomes a ```latex code block with the same id and sourceLine, so the view, the exact-height measurer, search, selection, Source Edit landing and print/PDF all use the existing code-block path. The decision goes through the MathRendering seam (`typesets`, a parse-only check that matches MTMathUILabel's own error test); measureMathRows measures a substituted pending row as an exact code row. Co-Authored-By: Claude Opus 5.5 --- QuickMD/QuickMD/BlockHeightMeasurer.swift | 81 +++++++++++-- QuickMD/QuickMD/MarkdownExport.swift | 5 + QuickMD/QuickMD/MarkdownView.swift | 16 ++- QuickMD/QuickMD/Views/MathBlockView.swift | 8 ++ .../BlockHeightMeasurerTests.swift | 112 ++++++++++++++++++ QuickMD/QuickMDTests/SearchTests.swift | 18 +++ 6 files changed, 224 insertions(+), 16 deletions(-) diff --git a/QuickMD/QuickMD/BlockHeightMeasurer.swift b/QuickMD/QuickMD/BlockHeightMeasurer.swift index 901f49e..b1f9b5e 100644 --- a/QuickMD/QuickMD/BlockHeightMeasurer.swift +++ b/QuickMD/QuickMD/BlockHeightMeasurer.swift @@ -496,11 +496,43 @@ struct MathRendering { let inlineImage: (_ latex: String, _ theme: MarkdownTheme, _ fontScale: CGFloat) -> NSImage? /// Height `MTMathUILabel` will take for a display-math block. let displayHeight: (_ latex: String, _ theme: MarkdownTheme, _ fontScale: CGFloat) -> CGFloat + /// False when the engine cannot typeset this display formula at all (an + /// unsupported command such as `\not\le`). `MTMathUILabel` would draw an + /// empty row for it, so `substitutingUntypesettableMath` shows the source + /// instead. Defaults to "always typesets" so stubs keep their old behaviour. + var typesets: (_ latex: String) -> Bool = { _ in true } /// No math engine: inline math degrades to the italic-literal fallback and /// display math to zero intrinsic height. Only for tests and previews. static let none = MathRendering(inlineImage: { _, _, _ in nil }, displayHeight: { _, _, _ in 0 }) + + /// `blocks` with every `.mathBlock` this engine cannot typeset turned into a + /// ```` ```latex ```` code block of the same LaTeX — same `id` (ids are + /// positional) and same `sourceLine`. + /// + /// Why a block substitution and not a fallback inside `MathBlockView`: the + /// row would then have to be a code block for the view, the measurer + /// (`.exact` height), search, selection/copy, Source Edit landing and + /// print/PDF alike — six places that would each need their own "failed + /// math" branch. Swapping the block once per parse, before anything is laid + /// out, gives all of them the existing code-block path. + /// + /// Same thread rule as `measure(math:)`: the engine behind `.swiftMath` is + /// main-thread only, so call this on the main thread with it (the parse + /// task's main-actor step, `PDFExportManager.generateMultiPagePDF` for + /// print and PDF). Documents without display math return without consulting + /// the engine. + func substitutingUntypesettableMath(in blocks: [MarkdownBlock]) -> [MarkdownBlock] { + guard blocks.contains(where: { if case .mathBlock = $0.content { return true } else { return false } }) + else { return blocks } + return blocks.map { block in + guard case .mathBlock(let latex) = block.content, !typesets(latex) else { return block } + return MarkdownBlock(id: block.id, + content: .codeBlock(code: latex, language: "latex"), + sourceLine: block.sourceLine) + } + } } // MARK: - AttributedString → NSAttributedString @@ -847,14 +879,10 @@ enum BlockHeightMeasurer { kinds.append(.exact) case .codeBlock(let code, let language): - let ns = BlockTextConverter.plainCode(code, theme: theme, fontScale: fontScale) - let inner = exactHeight(text: ns, width: codeWidth) - let stack = inner - + (language.isEmpty ? codeChromeWithoutLanguage : codeChromeWithLanguage) - // The always-present copy button is the ZStack's other child, so - // it is a floor under the row — see `Code.copyButtonFloorHeight`. - heights.append(max(stack, BlockLayout.Code.copyButtonFloorHeight) - + 2 * BlockLayout.Document.codeOuterVerticalPadding) + heights.append(codeRowHeight(code: code, theme: theme, fontScale: fontScale, + codeWidth: codeWidth, + chrome: language.isEmpty ? codeChromeWithoutLanguage + : codeChromeWithLanguage)) kinds.append(.exact) case .heading(let level, let title, _): @@ -921,6 +949,21 @@ enum BlockHeightMeasurer { mathPendingRows: mathPendingRows) } + /// One `.codeBlock` row: the code laid out at `codeWidth` plus `chrome` + /// (`BlockLayout.Code.verticalChrome` for the block's language state — + /// passed in so `measure` computes it once per document, not per block). + /// Shared by `measure` and `measureMathRows` (a display formula the engine + /// cannot typeset becomes a code row there). + private static func codeRowHeight(code: String, theme: MarkdownTheme, fontScale: CGFloat, + codeWidth: CGFloat, chrome: CGFloat) -> CGFloat { + let ns = BlockTextConverter.plainCode(code, theme: theme, fontScale: fontScale) + let stack = exactHeight(text: ns, width: codeWidth) + chrome + // The always-present copy button is the ZStack's other child, so it is + // a floor under the row — see `Code.copyButtonFloorHeight`. + return max(stack, BlockLayout.Code.copyButtonFloorHeight) + + 2 * BlockLayout.Document.codeOuterVerticalPadding + } + /// Finishes the rows an off-main `measure(math: nil)` deferred. /// /// `@MainActor` because this is the only place in the measurement path @@ -928,8 +971,9 @@ enum BlockHeightMeasurer { /// caller runs it after the background pass lands, before the table is used /// for layout. /// - /// Nothing else about the table changes — same `kinds`, same `contentWidth`, - /// every other height and converted string carried over — so + /// Nothing else about the table changes — same `kinds` (bar a substituted + /// formula, see `blocks`), same `contentWidth`, every other height and + /// converted string carried over — so /// `measureMathRows(m.mathPendingRows, in: m, …, math: engine)` equals a /// straight `measure(…, math: engine)`, row for row. /// @@ -937,7 +981,10 @@ enum BlockHeightMeasurer { /// - pending: normally `measured.mathPendingRows`. Indices outside the /// table, and rows that don't need the engine, are ignored. /// - blocks: the same array `measured` was produced from (row *i* is - /// `blocks[i]`). + /// `blocks[i]`), or that array after + /// `MathRendering.substitutingUntypesettableMath`: a deferred + /// `.mathBlock` row that became a `.codeBlock` is measured as a code row + /// here and its kind turns `.exact`, exactly what `measure` gives it. /// - contentWidth: MUST be the width `measured` was produced at. /// - reusing: strings a PREVIOUS measurement of the same content already /// built, keyed by block id — normally the caller's last @@ -971,6 +1018,7 @@ enum BlockHeightMeasurer { #endif var heights = measured.table.heights + var kinds = measured.table.kinds var converted = measured.converted let textWidth = BlockLayout.clampedWidth(contentWidth) @@ -1003,13 +1051,22 @@ enum BlockHeightMeasurer { + 2 * BlockLayout.Math.verticalPadding + 2 * BlockLayout.Document.mathOuterVerticalPadding + case .codeBlock(let code, let language): + // Only a substituted formula gets here (a parsed code block is + // never pending). Rare, so the chrome is computed per row. + heights[index] = codeRowHeight( + code: code, theme: theme, fontScale: fontScale, + codeWidth: BlockLayout.clampedWidth(contentWidth - 2 * BlockLayout.Code.horizontalPadding), + chrome: BlockLayout.Code.verticalChrome(hasLanguage: !language.isEmpty, fontScale: fontScale)) + kinds[index] = .exact + default: continue } } return MeasuredBlocks( - table: BlockHeightTable(heights: heights, kinds: measured.table.kinds, + table: BlockHeightTable(heights: heights, kinds: kinds, contentWidth: measured.table.contentWidth), converted: converted, tables: measured.tables, diff --git a/QuickMD/QuickMD/MarkdownExport.swift b/QuickMD/QuickMD/MarkdownExport.swift index 34cb31b..67ef152 100644 --- a/QuickMD/QuickMD/MarkdownExport.swift +++ b/QuickMD/QuickMD/MarkdownExport.swift @@ -781,6 +781,11 @@ class PDFExportManager { return nil } + // A display formula SwiftMath cannot typeset prints as the same + // ```latex code block the screen shows (MarkdownView's parse task makes + // the identical substitution). Main actor: the engine is main-only. + let blocks = MathRendering.swiftMath.substitutingUntypesettableMath(in: blocks) + // Measure each block's size in points var measuredBlocks: [(block: MarkdownBlock, size: CGSize)] = [] diff --git a/QuickMD/QuickMD/MarkdownView.swift b/QuickMD/QuickMD/MarkdownView.swift index bac246a..2106f2f 100644 --- a/QuickMD/QuickMD/MarkdownView.swift +++ b/QuickMD/QuickMD/MarkdownView.swift @@ -689,8 +689,16 @@ struct MarkdownView: View { // is what made repeated ⌘+/⌘- jump to arbitrary sizes. guard !Task.isCancelled else { return } let parsed = out.parsed + // A display formula SwiftMath cannot typeset would draw an empty + // row: show its source as a ```latex code block instead. Decided + // here, once per parse and before anything is laid out (the engine + // is main-thread only), so every consumer of `cachedBlocks` — view, + // measurer, search, selection, landing — sees the same block. Its + // row is still in `mathPendingRows`; `measureMathRows` measures it + // as code below. + let blocks = MathRendering.swiftMath.substitutingUntypesettableMath(in: parsed.blocks) heightCache.removeAll() // new content/theme — stale heights would mis-seed diagrams - cachedBlocks = parsed.blocks + cachedBlocks = blocks renderedFontScale = Double(scale) contentVersion += 1 textBlockMeta = parsed.textMeta @@ -700,11 +708,11 @@ struct MarkdownView: View { // table is used for layout. measured = width > 0 ? BlockHeightMeasurer.measureMathRows(out.measured.mathPendingRows, - in: out.measured, blocks: parsed.blocks, + in: out.measured, blocks: blocks, theme: currentTheme, fontScale: scale, contentWidth: width, math: .swiftMath) : .empty - headings = parsed.blocks.compactMap { block in + headings = blocks.compactMap { block in if case .heading(let level, let title, let sourceLine) = block.content { return ToCEntry(id: block.id, level: level, title: title, sourceLine: sourceLine) } @@ -714,7 +722,7 @@ struct MarkdownView: View { // left before the re-parse arrived): requested in the transaction // that installs the blocks, so it wins over the list's own anchor // restore and resolves against the new ids. - if let target = landing.blocksInstalled(parsed.blocks) { + if let target = landing.blocksInstalled(blocks) { requestScroll(to: target, anchor: .top, animated: false) } // The find bar's matches index the PREVIOUS parse's blocks: count diff --git a/QuickMD/QuickMD/Views/MathBlockView.swift b/QuickMD/QuickMD/Views/MathBlockView.swift index 2d205d3..a61cd39 100644 --- a/QuickMD/QuickMD/Views/MathBlockView.swift +++ b/QuickMD/QuickMD/Views/MathBlockView.swift @@ -54,6 +54,14 @@ extension MathRendering { let (error, image, _) = mathImage.asImage() guard error == nil, let image = image else { return 0 } return image.size.height + }, + // Exactly the test `MTMathUILabel.latex` applies: a parse error is what + // leaves the label (and `MathImage`) with nothing to draw. Parsing only — + // no font, no typesetting, no bitmap — so the per-parse check costs a + // fraction of the `displayHeight` call that follows for the same row. + typesets: { latex in + var error: NSError? + return MTMathListBuilder.build(fromString: latex, error: &error) != nil && error == nil } ) } diff --git a/QuickMD/QuickMDTests/BlockHeightMeasurerTests.swift b/QuickMD/QuickMDTests/BlockHeightMeasurerTests.swift index bd5cd55..5f2d80a 100644 --- a/QuickMD/QuickMDTests/BlockHeightMeasurerTests.swift +++ b/QuickMD/QuickMDTests/BlockHeightMeasurerTests.swift @@ -857,3 +857,115 @@ final class BlockHeightMeasurerTests: XCTestCase { ![alt text](image.png) """ } + +// MARK: - Untypesettable display math (1.14.3): the source as a ```latex block + +extension BlockHeightMeasurerTests { + + /// Stand-in for SwiftMath's parser: refuses `\not` the way the real + /// `MTMathListBuilder` does ("Unsupported \not\le combination"), accepts + /// everything else. + private var refusingNot: MathRendering { + MathRendering(inlineImage: { _, _, _ in NSImage(size: NSSize(width: 24, height: 12)) }, + displayHeight: { _, _, _ in 40 }, + typesets: { !$0.contains("\\not") }) + } + + private static let failingFormulaDocument = """ + Intro paragraph. + + $$ + V(e_1) \\not\\le V(e_2) \\quad \\land \\quad V(e_2) \\not\\le V(e_1) + $$ + + $$x = \\frac{1}{2}$$ + + Outro paragraph. + """ + + func testUntypesettableFormulaBecomesALatexCodeBlockKeepingIdAndSourceLine() { + let parsed = MarkdownBlockParser(theme: MarkdownTheme.cached(for: .light)).parse(Self.failingFormulaDocument) + let blocks = refusingNot.substitutingUntypesettableMath(in: parsed) + + XCTAssertEqual(blocks.count, parsed.count) + XCTAssertEqual(blocks.map(\.id), parsed.map(\.id), "ids are positional and must not change") + XCTAssertEqual(blocks.map(\.sourceLine), parsed.map(\.sourceLine)) + + guard case .mathBlock(let failing) = parsed[1].content else { + return XCTFail("fixture: row 1 is not display math") + } + guard case .codeBlock(let code, let language) = blocks[1].content else { + return XCTFail("the untypesettable formula is still \(blocks[1].content)") + } + XCTAssertEqual(code, failing, "the code block shows the formula's source verbatim") + XCTAssertEqual(language, "latex") + XCTAssertEqual(blocks[1].sourceLine, 2, "the opening $$ line") + + guard case .mathBlock(let working) = blocks[2].content else { + return XCTFail("a formula that typesets must stay display math") + } + XCTAssertEqual(working, "x = \\frac{1}{2}") + } + + /// The default engine stubs (and `.none`) never substitute — nothing that + /// existed before the fallback changes behaviour. + func testEnginesWithoutATypesetCheckKeepEveryFormula() { + let parsed = parse(Self.failingFormulaDocument) + for engine in [math, MathRendering.none] { + let blocks = engine.substitutingUntypesettableMath(in: parsed) + XCTAssertEqual(blocks.filter { if case .mathBlock = $0.content { return true } else { return false } }.count, 2) + } + } + + func testDocumentWithoutDisplayMathNeverConsultsTheEngine() { + let parsed = parse("Text with $x$ inline only.\n\n```latex\n\\not\\le\n```") + let engine = MathRendering(inlineImage: { _, _, _ in nil }, displayHeight: { _, _, _ in 0 }, + typesets: { latex in + XCTFail("asked to typeset \u{201C}\(latex)\u{201D} with no display math present") + return false + }) + XCTAssertEqual(engine.substitutingUntypesettableMath(in: parsed).map(\.id), parsed.map(\.id)) + } + + /// The production order: the off-main pass measured the ORIGINAL blocks (the + /// formula row deferred as math), the main actor substitutes, then + /// `measureMathRows` finishes. The result must equal a straight measurement + /// of the substituted blocks — the formula row an `.exact` code row whose + /// height is what the code-block view lays out. + @MainActor + func testSubstitutedFormulaRowIsMeasuredAsAnExactCodeRow() { + let engine = refusingNot + let parsed = parse(Self.failingFormulaDocument) + let blocks = engine.substitutingUntypesettableMath(in: parsed) + guard case .codeBlock(let code, let language) = blocks[1].content else { + return XCTFail("fixture: row 1 was not substituted") + } + for width in widths { + let deferred = measureWithoutMathEngine(parsed, width: width) + XCTAssertTrue(deferred.mathPendingRows.contains(1), "@\(width)") + XCTAssertEqual(deferred.table.kinds[1], .reported) + + let finished = BlockHeightMeasurer.measureMathRows( + deferred.mathPendingRows, in: deferred, blocks: blocks, theme: theme, + fontScale: 1.0, contentWidth: width, math: engine) + let direct = BlockHeightMeasurer.measure(blocks: blocks, theme: theme, fontScale: 1.0, + contentWidth: width, math: engine) + XCTAssertEqual(finished.table, direct.table, "@\(width)") + XCTAssertEqual(finished.table.kinds[1], .exact, "@\(width)") + XCTAssertEqual(finished.table.kinds[2], .reported, "the working formula stays a math row") + + // Independent of the measurer: the string CodeBlockView installs, laid + // out in a live text view, plus the code chrome from BlockLayout. + let ns = BlockTextConverter.plainCode(code, theme: theme, fontScale: 1.0) + let inner = liveTextHeight(ns, width: width - 2 * BlockLayout.Code.horizontalPadding) + XCTAssertFalse(language.isEmpty) + let chrome = BlockLayout.Code.languageLabelHeight(fontScale: 1.0) + + BlockLayout.Code.languageLabelTopPadding + + BlockLayout.Code.languageLabelBottomPadding + + 2 * BlockLayout.Code.verticalPaddingWithLanguage + let expected = max(inner + chrome, BlockLayout.Code.copyButtonFloorHeight) + + 2 * BlockLayout.Document.codeOuterVerticalPadding + XCTAssertEqual(finished.table.heights[1], expected, accuracy: tolerance, "@\(width)") + } + } +} diff --git a/QuickMD/QuickMDTests/SearchTests.swift b/QuickMD/QuickMDTests/SearchTests.swift index a16e147..de3241d 100644 --- a/QuickMD/QuickMDTests/SearchTests.swift +++ b/QuickMD/QuickMDTests/SearchTests.swift @@ -586,3 +586,21 @@ final class SearchTests: XCTestCase { XCTAssertEqual(count("key", in: markdown), 1, "a term only in a key is counted") } } + +// MARK: - Untypesettable display math (1.14.3) + +extension SearchTests { + /// A formula shown as its ```latex source is searched as that code block: + /// each occurrence counted once (not once as math and again as code). + func testUntypesettableFormulaIsCountedOnceAsItsCodeBlock() { + let engine = MathRendering(inlineImage: { _, _, _ in nil }, displayHeight: { _, _, _ in 40 }, + typesets: { !$0.contains("\\not") }) + let markdown = "$$\nV(e_1) \\not\\le V(e_2) \\quad \\land \\quad V(e_2) \\not\\le V(e_1)\n$$\n\nAfter." + let blocks = engine.substitutingUntypesettableMath(in: parse(markdown)) + guard case .codeBlock = blocks[0].content else { return XCTFail("formula was not substituted") } + + let results = DocumentSearch.computeMatches(in: blocks, term: "\\not\\le") + XCTAssertEqual(results.matchBlockIds, [blocks[0].id, blocks[0].id]) + XCTAssertEqual(DocumentSearch.computeMatches(in: blocks, term: "land").matchBlockIds, [blocks[0].id]) + } +} From 65bb4118cc7a5f9c98ca81e149657ca57fbe2c09 Mon Sep 17 00:00:00 2001 From: Bartosz Sroczynski Date: Wed, 4 Nov 2026 14:08:26 +0100 Subject: [PATCH 5/6] fix(appstore): no rating prompt right after returning from idle When 60 s of display completes while the user has been idle, the run is reset instead of staying complete, so a reader coming back is never asked on their first touch: it takes a fresh 60 s of display that ends with input in the last 60 s. Co-Authored-By: Claude Opus 5.5 --- QuickMD/QuickMD/ReviewPromptTrigger.swift | 31 ++++++++++++-------- QuickMD/QuickMDTests/ReviewPromptTests.swift | 13 ++++++++ 2 files changed, 32 insertions(+), 12 deletions(-) diff --git a/QuickMD/QuickMD/ReviewPromptTrigger.swift b/QuickMD/QuickMD/ReviewPromptTrigger.swift index e081eac..fbb67ce 100644 --- a/QuickMD/QuickMD/ReviewPromptTrigger.swift +++ b/QuickMD/QuickMD/ReviewPromptTrigger.swift @@ -27,8 +27,10 @@ enum ReviewPromptSession { /// unoccluded, key, no sheet or app-modal panel. A tick that fails it, losing /// key, or a gap of more than 3 s between ticks (sleep, App Nap, a stalled /// main thread — measured on the monotonic `systemUptime`) restarts the 60 s. -/// At 60 s it also needs user input within the last 60 s; an idle reader keeps -/// it waiting. Source Edit stops it (the task id changes and the task returns +/// At 60 s it also needs user input within the last 60 s; if the reader is +/// idle the run is reset, so someone returning after a long break is never +/// asked on their first touch — it takes a fresh 60 s of display that ends +/// with recent input. Source Edit stops it (the task id changes and the task returns /// while editing; leaving starts a fresh 60 s), closing the window cancels it /// with the view's task. /// @@ -94,17 +96,22 @@ struct ReviewPromptTrigger: ViewModifier { if displayedLongEnough { // Another window may have asked meanwhile, or the policy changed. guard Self.canRequest(policy) else { return } - // An unattended screen is not reading: keep waiting (the run - // stays complete) until the reader touches the Mac again. - if Self.userIsActive { - ReviewPromptSession.hasRequested = true - #if DEBUG - reviewLog.debug("requestReview for \(Self.appVersion, privacy: .public)") - #endif - requestReview() - policy.markRequested(now: Date(), appVersion: Self.appVersion) - return + // An unattended screen is not reading. Start over rather than + // keep the run complete: a reader coming back after a long + // idle must never get the prompt on their first touch — it + // takes another 60 s of display that ends with recent input. + guard Self.userIsActive else { + countdown.reset() + try? await Task.sleep(nanoseconds: Self.tick) + continue } + ReviewPromptSession.hasRequested = true + #if DEBUG + reviewLog.debug("requestReview for \(Self.appVersion, privacy: .public)") + #endif + requestReview() + policy.markRequested(now: Date(), appVersion: Self.appVersion) + return } try? await Task.sleep(nanoseconds: Self.tick) } diff --git a/QuickMD/QuickMDTests/ReviewPromptTests.swift b/QuickMD/QuickMDTests/ReviewPromptTests.swift index c59a9b4..7ee5cf6 100644 --- a/QuickMD/QuickMDTests/ReviewPromptTests.swift +++ b/QuickMD/QuickMDTests/ReviewPromptTests.swift @@ -277,6 +277,19 @@ final class ReviewPromptTests: XCTestCase { XCTAssertFalse(countdown.advance(isDisplayed: true, now: uptime + 60)) } + /// The trigger's idle path: a run that completed while the reader was + /// idle is reset, so the first tick after they come back does NOT + /// complete — it takes another full 60 s of display. + func testCompletedRunResetForIdleNeedsAFullNewRun() { + var countdown = ReviewPromptCountdown(required: 60) + XCTAssertTrue(tick(&countdown, from: uptime, through: uptime + 60)) + XCTAssertFalse(ReviewPrompt.isUserActive(secondsSinceLastInput: 600)) + countdown.reset() + XCTAssertFalse(countdown.advance(isDisplayed: true, now: uptime + 61), "first tick after idle") + XCTAssertFalse(tick(&countdown, from: uptime + 62, through: uptime + 120)) + XCTAssertTrue(countdown.advance(isDisplayed: true, now: uptime + 121)) + } + func testCountdownSurvivesTimeGoingBack() { var countdown = ReviewPromptCountdown(required: 60) tick(&countdown, from: uptime, through: uptime + 30) From 37989d43ba26ea0dad14de26d711f6df290d4b5c Mon Sep 17 00:00:00 2001 From: Bartosz Sroczynski Date: Wed, 4 Nov 2026 14:31:52 +0100 Subject: [PATCH 6/6] release: v1.14.3 (build 29) Blank lines around blocks no longer render as empty lines, a formula that cannot be typeset shows its LaTeX source, and the Mac App Store version may ask for a rating after real use. Co-Authored-By: Claude Opus 5.5 --- QuickMD/CHANGELOG.md | 11 +++++++++++ QuickMD/QuickMD.xcodeproj/project.pbxproj | 8 ++++---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/QuickMD/CHANGELOG.md b/QuickMD/CHANGELOG.md index 7bb5d02..4132ddb 100644 --- a/QuickMD/CHANGELOG.md +++ b/QuickMD/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes to QuickMD will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.14.3] - 2026-11-04 + +Two rendering fixes. + +### Fixed +- A blank line under a heading, or after a paragraph or list before the next heading, no longer shows as an extra empty line. A document looks the same with or without it, as on GitHub. +- A display formula that cannot be typeset (for example one with `\not\le`) shows its LaTeX source as a code block instead of an empty space. Formulas that typeset look as before. + +### Changed +- Mac App Store version: QuickMD may ask for a rating with the standard macOS prompt, at most once per version and no more than every four months. It asks only after documents were opened on at least three different days over a week or more, after a minute of reading, and never while you edit or right after the Mac sat idle. The count stays on your Mac. + ## [1.14.2] - 2026-11-04 Front matter you can read at a glance. diff --git a/QuickMD/QuickMD.xcodeproj/project.pbxproj b/QuickMD/QuickMD.xcodeproj/project.pbxproj index 339319a..286ee44 100644 --- a/QuickMD/QuickMD.xcodeproj/project.pbxproj +++ b/QuickMD/QuickMD.xcodeproj/project.pbxproj @@ -891,7 +891,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 28; + CURRENT_PROJECT_VERSION = 29; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = VJ92852M32; ENABLE_APP_SANDBOX = YES; @@ -916,7 +916,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.14.2; + MARKETING_VERSION = 1.14.3; PRODUCT_BUNDLE_IDENTIFIER = pl.falami.studio.QuickMD; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -935,7 +935,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 28; + CURRENT_PROJECT_VERSION = 29; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = VJ92852M32; ENABLE_APP_SANDBOX = YES; @@ -960,7 +960,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.14.2; + MARKETING_VERSION = 1.14.3; PRODUCT_BUNDLE_IDENTIFIER = pl.falami.studio.QuickMD; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = "";