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
46 changes: 28 additions & 18 deletions App/ScrollFrameRate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@ import QuartzCore
/// 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.
/// the most the window's screen can show (120 Hz on a ProMotion panel, 144 Hz
/// on such a monitor), and pauses when the page stops so an idle window costs
/// nothing. A screen that only runs at 60 Hz has no other rate to settle on,
/// so nothing is asked of it.
///
/// `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,
Expand All @@ -29,29 +31,33 @@ final class ScrollFrameRate: NSObject {
private static let linger: CFTimeInterval = 0.4

private var monitor: Any?
private var range: CAFrameRateRange?
/// The stored setting: nil for the screen's own maximum, 0 for off.
private var setting: Int?
private var link: CADisplayLink?
private weak var linkedView: NSView?
private var linkedScreenMaximum = 0
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)
/// The range to ask for on a screen that shows at most `screenMaximum`
/// frames per second, or nil when there is nothing to ask for. `setting`
/// is the stored preference: nil for the screen's maximum, 0 or less for
/// off, otherwise that rate, never above what the screen can show.
nonisolated static func range(setting: Int?, screenMaximum: Int) -> CAFrameRateRange? {
if let setting, setting <= 0 { return nil }
// A fixed-rate screen, unless a rate was asked for explicitly.
if setting == nil, screenMaximum <= 60 { return nil }
let target = min(setting ?? screenMaximum, max(screenMaximum, 1))
let rate = Float(target)
if target < 80 {
return CAFrameRateRange(minimum: rate, maximum: rate, preferred: rate)
default:
let rate = Float(min(setting, 120))
return CAFrameRateRange(minimum: 80, maximum: rate, preferred: rate)
}
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
setting = AppEnvironment.defaults.object(forKey: Self.settingKey) as? Int
if let setting, setting <= 0 { return }
monitor = NSEvent.addLocalMonitorForEvents(matching: .scrollWheel) { event in
MainActor.assumeIsolated { ScrollFrameRate.shared.scrolled(in: event.window) }
return event
Expand All @@ -60,16 +66,20 @@ final class ScrollFrameRate: NSObject {

private func scrolled(in window: NSWindow?) {
lastScrollAt = CACurrentMediaTime()
guard let view = window?.contentView, let range else { return }
if link == nil || linkedView !== view {
guard let view = window?.contentView else { return }
let screenMaximum = window?.screen?.maximumFramesPerSecond ?? 60
if link == nil || linkedView !== view || linkedScreenMaximum != screenMaximum {
link?.invalidate()
link = nil
linkedView = view
linkedScreenMaximum = screenMaximum
guard let range = Self.range(setting: setting, screenMaximum: screenMaximum) else { return }
// 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
}
Expand Down
11 changes: 5 additions & 6 deletions Features/Main/SessionListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,11 @@ struct SessionListView: View {
title: L10n.t("list.empty.title"),
message: L10n.t("list.empty.message"))
} else {
let byDay = sessionsByDay
List(selection: $selection) {
ForEach(days, id: \.self) { day in
ForEach(byDay.keys.sorted(by: >), id: \.self) { day in
Section(DateLabels.day(day)) {
ForEach(sessionsByDay[day] ?? []) { session in
ForEach(byDay[day] ?? []) { session in
SessionRow(session: session,
courseName: courseName(session),
isRecording: session.id == recordingSessionId)
Expand Down Expand Up @@ -82,14 +83,12 @@ struct SessionListView: View {
}
}

/// Grouped once per redraw; read per section, it was grouped again for
/// every day in the list.
private var sessionsByDay: [Date: [Session]] {
Dictionary(grouping: sessions) { Calendar.current.startOfDay(for: $0.startedDate) }
}

private var days: [Date] {
sessionsByDay.keys.sorted(by: >)
}

private func courseName(_ session: Session) -> String? {
guard let id = session.courseId else { return nil }
return courses.first { $0.id == id }?.name
Expand Down
9 changes: 8 additions & 1 deletion Features/Session/SessionDetailViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,15 @@ import AppKit
@MainActor
final class SessionDetailViewModel: ObservableObject {
@Published var session: SessionWithSegments? {
didSet { updatePlayingSegment() }
didSet {
sentenceBlocks = session?.segments.sentenceBlocks ?? []
updatePlayingSegment()
}
}
/// The transcript grouped into sentences, once per change to it rather
/// than on every redraw of the transcript (which follows every change to
/// this object, a streamed token included).
private(set) var sentenceBlocks: [SentenceBlock<Segment>] = []
@Published var note: Note?
@Published var noteVersions: [NoteVersion] = []
@Published var highlights: [Highlight] = []
Expand Down
18 changes: 11 additions & 7 deletions Features/Session/TranscriptView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ struct TranscriptView: View {
}

private func transcript(_ segments: [Segment]) -> some View {
let blocks = segments.sentenceBlocks
let blocks = vm.sentenceBlocks
let highlightStarts = Set(vm.highlights.map(\.timestampMs))
let playingId = vm.playingSegmentId
let flashId = vm.flashSegmentId
Expand Down Expand Up @@ -191,10 +191,14 @@ struct SentenceBlockView: View, Equatable {
}
.padding(.vertical, 9)
.padding(.horizontal, 10)
.background(
RoundedRectangle(cornerRadius: Theme.cornerMedium, style: .continuous)
.fill(background)
)
.background {
// Only the row under the playhead or the pointer has one; a clear
// shape behind every other row was still drawn and hit-tested.
if let background {
RoundedRectangle(cornerRadius: Theme.cornerMedium, style: .continuous)
.fill(background)
}
}
.onHover { hovering = $0 }
.animation(.easeOut(duration: 0.2), value: isActive)
}
Expand All @@ -203,10 +207,10 @@ struct SentenceBlockView: View, Equatable {
block.lines.contains { $0.rowKey == playingId || $0.rowKey == flashId }
}

private var background: Color {
private var background: Color? {
if isActive { return Theme.accentSoft }
if hovering { return Theme.rowHover }
return .clear
return nil
}

/// The sentence's lines as one paragraph. Each line is its own run, so a
Expand Down
33 changes: 21 additions & 12 deletions Features/Shared/SessionPresentation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,31 +66,40 @@ extension AudioSourceKind {
static var liveCases: [AudioSourceKind] { [.microphone, .system, .mixed] }
}

@MainActor
enum DateLabels {
/// "Today", "Yesterday", or the date, for grouping a session list.
static func day(_ date: Date) -> String {
let calendar = Calendar.current
if calendar.isDateInToday(date) { return L10n.t("date.today") }
if calendar.isDateInYesterday(date) { return L10n.t("date.yesterday") }
let f = DateFormatter()
f.locale = L10n.isChinese ? Locale(identifier: "zh_CN") : Locale(identifier: "en_US")
f.setLocalizedDateFormatFromTemplate(
calendar.isDate(date, equalTo: Date(), toGranularity: .year) ? "MMMMdEEEE" : "yMMMMd")
return f.string(from: date)
let template = calendar.isDate(date, equalTo: Date(), toGranularity: .year) ? "MMMMdEEEE" : "yMMMMd"
return formatter(template).string(from: date)
}

static func time(_ date: Date) -> String {
let f = DateFormatter()
f.locale = L10n.isChinese ? Locale(identifier: "zh_CN") : Locale(identifier: "en_US")
f.setLocalizedDateFormatFromTemplate("jmm")
return f.string(from: date)
formatter("jmm").string(from: date)
}

static func dateTime(_ date: Date) -> String {
formatter("yMMMdjmm").string(from: date)
}

/// One formatter per template and language, made once. Building a
/// formatter from a template is slow, and the session list built one for
/// every row as it scrolled into view.
private static var formatters: [String: DateFormatter] = [:]

private static func formatter(_ template: String) -> DateFormatter {
let chinese = L10n.isChinese
let key = "\(chinese ? "zh" : "en")|\(template)"
if let cached = formatters[key] { return cached }
let f = DateFormatter()
f.locale = L10n.isChinese ? Locale(identifier: "zh_CN") : Locale(identifier: "en_US")
f.setLocalizedDateFormatFromTemplate("yMMMdjmm")
return f.string(from: date)
f.locale = chinese ? Locale(identifier: "zh_CN") : Locale(identifier: "en_US")
f.timeZone = .autoupdatingCurrent
f.setLocalizedDateFormatFromTemplate(template)
formatters[key] = f
return f
}
}

Expand Down
5 changes: 3 additions & 2 deletions Packages/MarkdownRendering/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@ let package = Package(
.library(name: "MarkdownRendering", targets: ["MarkdownRendering"]),
],
dependencies: [
// A copy of MarkdownView 3.0.0 with its formula rendering patched;
// see Packages/MarkdownView/PATCHES.md.
.package(
url: "https://github.com/LiYanan2004/MarkdownView",
from: "3.0.0",
path: "../MarkdownView",
traits: ["LaTeX"]
),
],
Expand Down
21 changes: 21 additions & 0 deletions Packages/MarkdownView/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2022 LiYanan2004

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
35 changes: 35 additions & 0 deletions Packages/MarkdownView/PATCHES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# MarkdownView, patched

[LiYanan2004/MarkdownView](https://github.com/LiYanan2004/MarkdownView) **3.0.0**
(MIT, see `LICENSE`), with one change: how a formula is drawn. Everything
else in `Sources/` is the release as published; the upstream DocC catalog and
tests are left out.

## Formulas are typeset once and drawn as images

Upstream, every formula, inline or display, was a `ViewThatFits` holding two
live `MTMathUILabel`s (AppKit views), one of them inside a horizontal scroll
view. Each label parsed its LaTeX again on every view update and ran SwiftMath's
whole typesetter on every size query, with no cache. A paragraph of notes with
a few formulas therefore created a dozen AppKit views and typeset each formula
several times whenever it scrolled into view, which is enough to miss frames at
120 Hz, and more on slower Macs.

Now `SwiftMathImageCache` parses and typesets a formula once per look (LaTeX,
size, inline or display, light or dark) with SwiftMath's own `MTMathImage`, and
the view draws that image. The image draws the typeset glyphs on demand, so it
is sharp on any screen. A formula wider than the space it has is scaled down to
fit instead of scrolling sideways, and LaTeX the typesetter cannot read is shown
as its source instead of nothing.

Files changed:

- `Sources/MarkdownView/Rendering/Shared/Math/SwiftMathView.swift`
- `Sources/MarkdownView/Rendering/Shared/Math/InlineMath.swift`
- `Sources/MarkdownView/Rendering/Shared/Math/MarkdownDisplayMathView.swift`

## Updating

Copy `Sources/MarkdownView` and `Package.swift` from the new release (leaving
out `Documentation.docc` and the test target), then apply the change above to
the three files again, and update the version at the top of this file.
61 changes: 61 additions & 0 deletions Packages/MarkdownView/Package.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// swift-tools-version: 6.2

import PackageDescription

// LiYanan2004/MarkdownView 3.0.0, kept here so its formula rendering can be
// changed; see PATCHES.md. Everything else is upstream as released.
let package = Package(
name: "MarkdownView",
platforms: [
.macOS(.v13),
.iOS(.v16),
.tvOS(.v16),
.watchOS(.v9),
.visionOS(.v1),
],
products: [
.library(name: "MarkdownView", targets: ["MarkdownView"]),
],
traits: [
"LaTeX",
.default(enabledTraits: ["LaTeX"]),
],
dependencies: [
.package(url: "https://github.com/swiftlang/swift-markdown.git", from: "0.8.0"),
.package(url: "https://github.com/raspu/Highlightr.git", from: "2.3.0"),
.package(url: "https://github.com/mgriebling/SwiftMath.git", from: "1.7.3"),
.package(url: "https://github.com/LiYanan2004/RichText.git", from: "1.0.0"),
],
targets: [
.target(
name: "MarkdownView",
dependencies: [
.product(
name: "Markdown",
package: "swift-markdown"
),
.product(
name: "Highlightr",
package: "Highlightr",
condition: .when(platforms: [.iOS, .macOS])
),
.product(
name: "SwiftMath",
package: "SwiftMath",
condition: .when(platforms: [.iOS, .macOS], traits: ["LaTeX"])
),
.product(
name: "RichText",
package: "RichText",
condition: .when(platforms: [.iOS, .macOS])
),
],
swiftSettings: [
.define(
"ENABLE_MATH_RENDERING",
.when(platforms: [.iOS, .macOS], traits: ["LaTeX"])
),
]
),
]
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
//
// AllowingModifyThroughKeyPath.swift
// MarkdownView
//
// Created by Yanan Li on 2025/2/9.
//

import Foundation

protocol AllowingModifyThroughKeyPath { }

extension AllowingModifyThroughKeyPath {
func with<T>(_ keyPath: WritableKeyPath<Self, T>, _ newValue: T) -> Self {
var copy = self
copy[keyPath: keyPath] = newValue
return copy
}
}
Loading
Loading