Skip to content

Hold the screen's full refresh rate on any Mac: cached formulas, lighter rows; 0.16.8 - #23

Merged
belugaming merged 6 commits into
mainfrom
claude/admiring-lovelace-9rinej
Oct 9, 2026
Merged

belugaming merged 6 commits into
mainfrom
claude/admiring-lovelace-9rinej

Conversation

@belugaming

Copy link
Copy Markdown
Owner

#21 fixed the refresh-rate flicker on macOS 27 ProMotion panels. To hold 120 Hz, or whatever the screen supports, every frame also has to finish in about 8 ms on the main thread. That has to hold on slower Macs too, not only on an M5 Max. This PR removes the biggest per-frame costs that are left on the scrolling pages.

Formulas: typeset once, drawn as an image

MarkdownView drew every formula as a ViewThatFits holding two live MTMathUILabels (AppKit views), one of them inside a horizontal scroll view.

  • Each label parsed its LaTeX again on every update.
  • Each label ran SwiftMath's whole typesetter on every size query, with no cache.
  • So a paragraph of notes with a few formulas created a dozen AppKit views and typeset each formula several times whenever it scrolled into view.
  • Formulas only came back in 0.16.5, so this cost is new for most users.

MarkdownView 3.0.0 is now a copy in Packages/MarkdownView (MIT, license kept), with only the formula drawing changed:

  • SwiftMathImageCache typesets a formula once per look (LaTeX, size, inline or display, light or dark). It uses SwiftMath's own MTMathImage.
  • The view draws that image. It is vector-backed, so it stays sharp at any scale.
  • A formula wider than its line is scaled to fit instead of scrolling sideways.
  • LaTeX the typesetter rejects shows as its source, where it used to show nothing.

Packages/MarkdownView/PATCHES.md lists the three changed files and how to update the copy. MarkdownRendering now depends on the copy by path, still with the LaTeX trait, so the CI check for ENABLE_MATH_RENDERING and SwiftMath's resources still applies.

Refresh-rate request follows the screen

The scroll-time request from #21 now asks for the window's screen maximum, taken from NSScreen.maximumFramesPerSecond, instead of a fixed 120 Hz. A 144 Hz monitor gets 144 Hz. A 60 Hz screen has no other rate to settle on, so nothing is asked of it. The scrollFrameRate setting still overrides this, capped at what the screen can show.

Transcript and session list

  • The transcript is grouped into sentences once per change, not on every redraw. A redraw happens on every change to the session's view model, including each streamed token.
  • A transcript row draws its rounded background only while it is highlighted or under the pointer. Before, a clear shape sat behind every row.
  • Date formatters are made once per template and language. Before, the session list made one per row and section header as it scrolled.
  • The session list groups sessions by day once per redraw. Before, it regrouped them once per day header.

Tests

  • FormulaRenderingTests renders $$x^{2}$$ through RichMarkdownView with ImageRenderer and checks the pixels:

    • something is drawn;
    • the exponent sits clearly above the x, which raw $$x^{2}$$ source or an upside-down image would fail.

    The old AppKit label rendered nothing off screen.

  • ScrollFrameRateTests covers the screen-dependent range: 120 Hz ProMotion, a 144 Hz monitor, 60 Hz left alone, the setting capped at the screen, and every range well formed.

Not measured on hardware. CI builds and tests on macOS 15 and 27, but has no display to time scrolling against. Whether the scrolling pages now hold the full rate has to be seen on a real Mac.

Version 0.16.8 (build 26).

🤖 Generated with Claude Code

https://claude.ai/code/session_01UP49VoRGBw2wv1CEAroky7


Generated by Claude Code

claude added 6 commits October 9, 2026 05:05
…oming 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UP49VoRGBw2wv1CEAroky7
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UP49VoRGBw2wv1CEAroky7
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UP49VoRGBw2wv1CEAroky7
…ter rows; 0.16.8

Formulas. MarkdownView drew every formula as two live MTMathUILabels (one
in a horizontal scroll view) that parsed their LaTeX on every update and
ran the whole typesetter on every size query, so a paragraph of notes with
formulas built a dozen AppKit views and typeset each formula several times
as it scrolled into view. MarkdownView 3.0.0 is now a copy in
Packages/MarkdownView with that one part changed: a formula is typeset
once with SwiftMath's MTMathImage, cached, and drawn as an image, scaled
to fit when wider than its line. PATCHES.md says what changed and how to
update the copy. A test renders x² through the app's Markdown view and
checks from the pixels that it is drawn, typeset and the right way up.

Refresh rate. The scrolling request asks for the most the window's screen
can show (144 Hz on such a monitor, not 120) and asks nothing of a 60 Hz
screen, which has no other rate to settle on.

Transcript. Sentences are grouped once per change to the transcript, not
on every redraw (a streamed token included), and a row draws its rounded
background only while it is highlighted or under the pointer instead of a
clear one behind every row.

Session list and dates. Date formatters are made once per template and
language rather than one per row, and the list groups sessions by day once
per redraw instead of once per day.

Version 0.16.8 (build 26).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UP49VoRGBw2wv1CEAroky7
The branch still carried #21's commits, which main has as a squash; this
keeps the branch's files exactly as they were.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UP49VoRGBw2wv1CEAroky7
MarkdownView already declares it (Image++.swift), so a second one in
SwiftMathView.swift did not compile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UP49VoRGBw2wv1CEAroky7
@belugaming
belugaming merged commit 4a0b29e into main Oct 9, 2026
6 checks passed
belugaming pushed a commit that referenced this pull request Oct 9, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UP49VoRGBw2wv1CEAroky7
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants