Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions App/ClassNoteApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
82 changes: 82 additions & 0 deletions App/ScrollFrameRate.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}
26 changes: 23 additions & 3 deletions Features/LiveSession/LiveSessionView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -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 {
Expand All @@ -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<LiveSegment>
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
Expand Down
15 changes: 13 additions & 2 deletions Features/Session/StudyView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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) }
}

Expand Down
55 changes: 55 additions & 0 deletions Features/Shared/BottomFollow.swift
Original file line number Diff line number Diff line change
@@ -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<Bool>) -> 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
}
}
}
43 changes: 43 additions & 0 deletions Tests/ClassNoteTests/BottomFollowTests.swift
Original file line number Diff line number Diff line change
@@ -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)))
}
}
47 changes: 47 additions & 0 deletions Tests/ClassNoteTests/ScrollFrameRateTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
4 changes: 2 additions & 2 deletions project.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading