diff --git a/App/ClassNoteApp.swift b/App/ClassNoteApp.swift index 60900ad..3ea6c8c 100644 --- a/App/ClassNoteApp.swift +++ b/App/ClassNoteApp.swift @@ -60,6 +60,7 @@ struct ClassNoteApp: App { init() { AppBootstrap.run() WindowRouter.shared.start() + ScrollFrameRate.shared.start() // The test bundle is hosted by this app, so a bootstrap here would race // every test: `loadConfig()` overwrites whatever config a test just set, // `refreshMicrophoneDevices()` writes back a preference, and diff --git a/App/ScrollFrameRate.swift b/App/ScrollFrameRate.swift new file mode 100644 index 0000000..a80651e --- /dev/null +++ b/App/ScrollFrameRate.swift @@ -0,0 +1,82 @@ +import AppKit +import QuartzCore + +/// Asks for the display's full refresh rate for as long as the user scrolls. +/// +/// On macOS 27 a ProMotion panel does not settle on one refresh rate while a +/// SwiftUI view scrolls: it flips between 120 Hz and 60 Hz, each frame stays +/// on screen for an unpredictable time, and scrolling judders however little +/// is drawn, on every page of the app, plain text included. Apple has the +/// report (FB24091347, Developer Forums thread 840214) and no fix yet; the +/// only confirmed workaround is setting the whole display to 60 Hz. +/// +/// What an app can do is say which rate it wants. A display link carrying a +/// preferred frame-rate range is that request, so one runs from the first +/// scroll event until shortly after the last (momentum included), asking for +/// a steady 120 Hz, and pauses when the page stops so an idle window costs +/// nothing. +/// +/// `defaults write com.beluga.classnote scrollFrameRate -int 60` asks for a +/// steady 60 Hz instead, and `0` turns the request off, from the next launch, +/// so either can be compared on the machine that judders without a new build. +@MainActor +final class ScrollFrameRate: NSObject { + static let shared = ScrollFrameRate() + + static let settingKey = "scrollFrameRate" + /// How long after the last scroll event the request is held, so a pause + /// between two flicks does not drop the rate in the middle of reading. + private static let linger: CFTimeInterval = 0.4 + + private var monitor: Any? + private var range: CAFrameRateRange? + private var link: CADisplayLink? + private weak var linkedView: NSView? + private var lastScrollAt: CFTimeInterval = 0 + + /// The range to ask for at `setting` frames per second, or nil for none. + nonisolated static func range(forSetting setting: Int) -> CAFrameRateRange? { + switch setting { + case ..<1: return nil + case ..<80: + let rate = Float(setting) + return CAFrameRateRange(minimum: rate, maximum: rate, preferred: rate) + default: + let rate = Float(min(setting, 120)) + return CAFrameRateRange(minimum: 80, maximum: rate, preferred: rate) + } + } + + func start() { + guard monitor == nil else { return } + let setting = AppEnvironment.defaults.object(forKey: Self.settingKey) as? Int ?? 120 + guard let range = Self.range(forSetting: setting) else { return } + self.range = range + monitor = NSEvent.addLocalMonitorForEvents(matching: .scrollWheel) { event in + MainActor.assumeIsolated { ScrollFrameRate.shared.scrolled(in: event.window) } + return event + } + } + + private func scrolled(in window: NSWindow?) { + lastScrollAt = CACurrentMediaTime() + guard let view = window?.contentView, let range else { return } + if link == nil || linkedView !== view { + link?.invalidate() + // A view's display link follows the view to whichever screen it is + // on, and that screen's refresh rate. + let newLink = view.displayLink(target: self, selector: #selector(tick(_:))) + newLink.preferredFrameRateRange = range + newLink.add(to: .main, forMode: .common) + link = newLink + linkedView = view + } + link?.isPaused = false + } + + @objc private func tick(_ link: CADisplayLink) { + if CACurrentMediaTime() - lastScrollAt > Self.linger { + link.isPaused = true + } + } +} diff --git a/Features/LiveSession/LiveSessionView.swift b/Features/LiveSession/LiveSessionView.swift index a664f46..36fb17f 100644 --- a/Features/LiveSession/LiveSessionView.swift +++ b/Features/LiveSession/LiveSessionView.swift @@ -271,6 +271,8 @@ struct LiveTranscript: View { var fontSize: Double var displayMode: OverlayCaptionDisplayMode var engineStatus: String + /// Off while the reader has scrolled up; see `BottomFollow`. + @State private var followsEnd = true var body: some View { if buffer.segments.isEmpty && buffer.draftText.isEmpty { @@ -295,7 +297,10 @@ struct LiveTranscript: View { ScrollView { LazyVStack(alignment: .leading, spacing: 18) { ForEach(buffer.segments.sentenceBlocks) { block in + // The draft changes with every word; the sentences + // above it only when one of them does. LiveSentenceView(block: block, fontSize: fontSize, displayMode: displayMode) + .equatable() } if !buffer.draftText.isEmpty { draft @@ -307,15 +312,22 @@ struct LiveTranscript: View { .padding(.vertical, 20) .frame(maxWidth: .infinity) } + .tracksBottomFollow($followsEnd) .onChange(of: buffer.segments.count) { _, _ in + guard followsEnd else { return } withAnimation(.easeOut(duration: 0.2)) { proxy.scrollTo("bottom", anchor: .bottom) } } - .onChange(of: buffer.draftText) { _, _ in proxy.scrollTo("bottom", anchor: .bottom) } - .onChange(of: buffer.draftTranslated) { _, _ in proxy.scrollTo("bottom", anchor: .bottom) } + .onChange(of: buffer.draftText) { _, _ in scrollToEnd(proxy) } + .onChange(of: buffer.draftTranslated) { _, _ in scrollToEnd(proxy) } } } } + private func scrollToEnd(_ proxy: ScrollViewProxy) { + guard followsEnd else { return } + proxy.scrollTo("bottom", anchor: .bottom) + } + private var draft: some View { VStack(alignment: .leading, spacing: 6) { if displayMode != .translation { @@ -334,11 +346,19 @@ struct LiveTranscript: View { } } -private struct LiveSentenceView: View { +/// Equatable on what it shows, so a new word in the draft below does not +/// redraw every sentence on screen. +private struct LiveSentenceView: View, Equatable { let block: SentenceBlock let fontSize: Double let displayMode: OverlayCaptionDisplayMode + nonisolated static func == (lhs: LiveSentenceView, rhs: LiveSentenceView) -> Bool { + lhs.block.lines == rhs.block.lines + && lhs.fontSize == rhs.fontSize + && lhs.displayMode == rhs.displayMode + } + private var original: String { SentenceGroups.join(block.lines.map(\.original)) } private var translation: String { block.lines.last?.translated ?? "" } /// Read off the last line, which is the one a sentence's translation lands diff --git a/Features/Session/StudyView.swift b/Features/Session/StudyView.swift index 3031c69..98ef5c0 100644 --- a/Features/Session/StudyView.swift +++ b/Features/Session/StudyView.swift @@ -68,6 +68,8 @@ struct QAPane: View { @ObservedObject var vm: SessionDetailViewModel @State private var question = "" @State private var confirmingClear = false + /// Off while the reader has scrolled up; see `BottomFollow`. + @State private var followsEnd = true @FocusState private var focused: Bool var body: some View { @@ -99,8 +101,15 @@ struct QAPane: View { .frame(maxWidth: .infinity) } } - .onChange(of: vm.qaMessages.count) { _, _ in scrollToBottom(proxy, lastRowId: rows.last?.id) } - .onChange(of: vm.streamingQAResponse) { _, _ in scrollToBottom(proxy, lastRowId: rows.last?.id) } + .tracksBottomFollow($followsEnd) + .onChange(of: vm.qaMessages.count) { _, _ in + guard followsEnd else { return } + scrollToBottom(proxy, lastRowId: rows.last?.id) + } + .onChange(of: vm.streamingQAResponse) { _, _ in + guard followsEnd else { return } + scrollToBottom(proxy, lastRowId: rows.last?.id) + } } if !vm.transcriptTruncatedNotice.isEmpty && vm.isAnsweringQuestion { LocalContextNotice(text: vm.transcriptTruncatedNotice).padding(.bottom, 4) @@ -153,6 +162,8 @@ struct QAPane: View { let q = question.trimmingCharacters(in: .whitespacesAndNewlines) guard !q.isEmpty, !vm.isAnsweringQuestion else { return } question = "" + // A question just asked is followed wherever the reader was. + followsEnd = true Task { await vm.askQuestion(q) } } diff --git a/Features/Shared/BottomFollow.swift b/Features/Shared/BottomFollow.swift new file mode 100644 index 0000000..1b9d6e5 --- /dev/null +++ b/Features/Shared/BottomFollow.swift @@ -0,0 +1,55 @@ +import SwiftUI + +/// Whether text that keeps growing (a live transcript, an answer being +/// written) should keep scrolling itself to its end. It does until the reader +/// scrolls up to look at something, and again once they scroll back down to +/// the end. Text growing under a reader who has not moved is not a scroll up, +/// so following survives every new line. +/// +/// Before this, each new word scrolled to the end unconditionally, several +/// times a second, so a reader who scrolled up was dragged straight back down +/// and the page looked like it could not be scrolled. +enum BottomFollow { + struct Position: Equatable { + /// How far the content is scrolled from its top. + var offset: CGFloat + /// How much content lies below the visible part. + var distanceToEnd: CGFloat + } + + /// Within this of the end counts as at the end. + static let slack: CGFloat = 40 + + static func following(_ following: Bool, from old: Position, to new: Position) -> Bool { + if new.distanceToEnd <= slack { return true } + if new.offset < old.offset - 1 { return false } + return following + } +} + +extension View { + /// Keeps `following` in step with the reader's scrolling. macOS 14 cannot + /// read where a scroll view is, so there it stays as it is and the view + /// follows the end as it always did. + func tracksBottomFollow(_ following: Binding) -> some View { + modifier(BottomFollowTracker(following: following)) + } +} + +private struct BottomFollowTracker: ViewModifier { + @Binding var following: Bool + + func body(content: Content) -> some View { + if #available(macOS 15.0, *) { + content.onScrollGeometryChange(for: BottomFollow.Position.self) { geometry in + BottomFollow.Position(offset: geometry.contentOffset.y, + distanceToEnd: geometry.contentSize.height - geometry.visibleRect.maxY) + } action: { old, new in + let next = BottomFollow.following(following, from: old, to: new) + if next != following { following = next } + } + } else { + content + } + } +} diff --git a/Tests/ClassNoteTests/BottomFollowTests.swift b/Tests/ClassNoteTests/BottomFollowTests.swift new file mode 100644 index 0000000..cd8637f --- /dev/null +++ b/Tests/ClassNoteTests/BottomFollowTests.swift @@ -0,0 +1,43 @@ +import XCTest +@testable import ClassNote + +/// When a growing transcript or answer keeps scrolling itself to its end. +/// Every new word used to scroll to the end, so a reader who scrolled up was +/// pulled back down several times a second. +final class BottomFollowTests: XCTestCase { + private func position(_ offset: CGFloat, _ distanceToEnd: CGFloat) -> BottomFollow.Position { + BottomFollow.Position(offset: offset, distanceToEnd: distanceToEnd) + } + + func testScrollingUpStopsFollowing() { + XCTAssertFalse(BottomFollow.following(true, from: position(800, 0), to: position(700, 100))) + } + + /// A new line pushes the end down without the reader moving. + func testNewTextBelowKeepsFollowing() { + XCTAssertTrue(BottomFollow.following(true, from: position(800, 0), to: position(800, 120))) + } + + func testNewTextBelowDoesNotStartFollowingAgain() { + XCTAssertFalse(BottomFollow.following(false, from: position(300, 500), to: position(300, 620))) + } + + func testReachingTheEndFollowsAgain() { + XCTAssertTrue(BottomFollow.following(false, from: position(700, 100), to: position(790, 10))) + } + + /// The scroll view moves content up when rows above are measured smaller; + /// a reader still at the end is still following. + func testOffsetCorrectionAtTheEndKeepsFollowing() { + XCTAssertTrue(BottomFollow.following(true, from: position(800, 0), to: position(760, 0))) + } + + func testScrollingDownShortOfTheEndChangesNothing() { + XCTAssertFalse(BottomFollow.following(false, from: position(300, 500), to: position(400, 400))) + } + + /// Content shorter than the window is always at its end. + func testShortContentFollows() { + XCTAssertTrue(BottomFollow.following(false, from: position(0, -200), to: position(0, -150))) + } +} diff --git a/Tests/ClassNoteTests/ScrollFrameRateTests.swift b/Tests/ClassNoteTests/ScrollFrameRateTests.swift new file mode 100644 index 0000000..6e48a5f --- /dev/null +++ b/Tests/ClassNoteTests/ScrollFrameRateTests.swift @@ -0,0 +1,47 @@ +import QuartzCore +import XCTest +@testable import ClassNote + +/// The refresh rate asked for while scrolling, from the `scrollFrameRate` +/// setting. A range CADisplayLink cannot take (a preferred rate above the +/// maximum) raises an exception, so each one must be well formed. +final class ScrollFrameRateTests: XCTestCase { + private func assertWellFormed(_ range: CAFrameRateRange, file: StaticString = #filePath, line: UInt = #line) { + XCTAssertLessThanOrEqual(range.minimum, range.preferred ?? range.maximum, file: file, line: line) + XCTAssertLessThanOrEqual(range.preferred ?? range.minimum, range.maximum, file: file, line: line) + } + + func testDefaultAsksForTheFullRate() throws { + let range = try XCTUnwrap(ScrollFrameRate.range(forSetting: 120)) + XCTAssertEqual(range.maximum, 120) + XCTAssertEqual(range.preferred, 120) + assertWellFormed(range) + } + + func testSixtyAsksForASteadySixty() throws { + let range = try XCTUnwrap(ScrollFrameRate.range(forSetting: 60)) + XCTAssertEqual(range.minimum, 60) + XCTAssertEqual(range.maximum, 60) + XCTAssertEqual(range.preferred, 60) + } + + func testZeroOrLessTurnsTheRequestOff() { + XCTAssertNil(ScrollFrameRate.range(forSetting: 0)) + XCTAssertNil(ScrollFrameRate.range(forSetting: -1)) + } + + func testRatesAboveTheDisplayAreCapped() throws { + let range = try XCTUnwrap(ScrollFrameRate.range(forSetting: 240)) + XCTAssertEqual(range.maximum, 120) + assertWellFormed(range) + } + + func testEverySettingGivesARangeADisplayLinkAccepts() { + for setting in [1, 30, 79, 80, 90, 119, 120, 144, 1000] { + guard let range = ScrollFrameRate.range(forSetting: setting) else { + return XCTFail("no range for \(setting)") + } + assertWellFormed(range) + } + } +} diff --git a/project.yml b/project.yml index 862c242..49d92d5 100644 --- a/project.yml +++ b/project.yml @@ -15,8 +15,8 @@ settings: SWIFT_STRICT_CONCURRENCY: complete SWIFT_TREAT_WARNINGS_AS_ERRORS: "NO" ENABLE_USER_SCRIPT_SANDBOXING: NO - MARKETING_VERSION: "0.16.5" - CURRENT_PROJECT_VERSION: "23" + MARKETING_VERSION: "0.16.6" + CURRENT_PROJECT_VERSION: "24" DEAD_CODE_STRIPPING: YES packages: