From 0010974f747619904f54a650cb0049599bc102ab Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 05:05:42 +0000 Subject: [PATCH 1/2] Let the reader scroll up while a live transcript or answer is still coming in The live window scrolled to its end on every new word, and the Q&A on every token of an answer, several times a second, so scrolling up was undone at once and the page looked like it could not be scrolled. Both now follow the end only while the reader is at it: scrolling up stops following, getting back to the end resumes it, and asking a question follows the answer. macOS 14 cannot read a scroll view's position, so it follows as before. The live window's sentences are Equatable on what they show, so a word added to the line still being spoken no longer redraws every sentence on screen. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UP49VoRGBw2wv1CEAroky7 --- Features/LiveSession/LiveSessionView.swift | 26 +++++++-- Features/Session/StudyView.swift | 15 +++++- Features/Shared/BottomFollow.swift | 55 ++++++++++++++++++++ Tests/ClassNoteTests/BottomFollowTests.swift | 43 +++++++++++++++ 4 files changed, 134 insertions(+), 5 deletions(-) create mode 100644 Features/Shared/BottomFollow.swift create mode 100644 Tests/ClassNoteTests/BottomFollowTests.swift 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 fe1889c..eb686dd 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))) + } +} From faade375b1c1133b91404001b0403350b68017ba Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 06:04:03 +0000 Subject: [PATCH 2/2] Ask for a steady refresh rate while scrolling; 0.16.6 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 every page judders, plain text included (FB24091347, Developer Forums thread 840214). The only confirmed workaround is setting the whole display to 60 Hz. An app can say which rate it wants: a display link with a preferred frame-rate range is that request. One now runs from the first scroll event until shortly after the last, momentum included, asking for 120 Hz, and pauses when the page stops. `defaults write com.beluga.classnote scrollFrameRate -int 60` asks for a steady 60 Hz instead and `0` turns it off, from the next launch, so the two can be compared without a new build. Version 0.16.6 (build 24). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UP49VoRGBw2wv1CEAroky7 --- App/ClassNoteApp.swift | 1 + App/ScrollFrameRate.swift | 82 +++++++++++++++++++ .../ClassNoteTests/ScrollFrameRateTests.swift | 47 +++++++++++ project.yml | 4 +- 4 files changed, 132 insertions(+), 2 deletions(-) create mode 100644 App/ScrollFrameRate.swift create mode 100644 Tests/ClassNoteTests/ScrollFrameRateTests.swift 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/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: