Skip to content

feat(tao): LCD/ClearType text on Windows - #626

Merged
kdroidFilter merged 1 commit into
nucleus-2.6from
feat/tao-lcd-cleartype-text
Sep 1, 2026
Merged

feat(tao): LCD/ClearType text on Windows#626
kdroidFilter merged 1 commit into
nucleus-2.6from
feat/tao-lcd-cleartype-text

Conversation

@kdroidFilter

@kdroidFilter kdroidFilter commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Summary

Compose Desktop hardcodes grayscale antialiasing as the Windows font-smoothing default (JetBrains/compose-multiplatform#875, tracked upstream as CMP-5359 — their own source comments they want ClearType but never wired the OS query). This PR enables LCD/ClearType text end to end, with no runtime reflection, working identically under HotSpot, ProGuard, and GraalVM native-image.

Paragraph half — Gradle plugin bytecode patch

  • LcdTextDefaultTransform: a Gradle artifact transform that rewrites FontRasterizationSettings$Companion.getPlatformDefault() in ui-text-desktop jars via ASM — the original getter is kept (renamed) and a cached wrapper returns SubpixelAntiAlias on Windows, delegating everywhere else.
  • Opt-outs: -Dnucleus.text.lcd=false (runtime), -Pnucleus.text.lcd.patch=false (build-time, skips the transform entirely).
  • Applied to non-test *RuntimeClasspath only, with the same Android / Compose Hot Reload / KMP-jar-variant guards as registerCleanNativeLibsTransform (verified: :examples:cmp-demo resolves).
  • Compose layout drift fails the build loudly: the patcher verifies the getter, the constructor descriptor, and the enum fields it references. The regression test patches the real artifact for both the plugin's Compose version and the consumer version parsed from the root version catalog.

Surface half — Tao backend

  • lcdSurfaceProps attaches the OS-queried pixel geometry (new SPI_GETFONTSMOOTHING* native query; cached; RGB/BGR; grayscale on any unknown, including a failed orientation query) to opaque Windows window surfaces only.
  • Per-pixel-alpha surfaces keep an unknown geometry so Skia falls back to grayscale: popups (DComp premultiplied swapchains), the NativeView blending overlay, Mica/Acrylic backdrops (runtime transparentBackgroundState), and transparent windows.
  • renderGlFrame now requires windowTransparent explicitly — no unsafe default.

Misc

  • jewel-demo refactored to JewelDecoratedWindow (theme outside the window, ~20 lines of hand-rolled deco theming removed).
  • __pycache__/ gitignored.

Test plan

  • LcdTextDefaultTransformTest — patches real ui-text-desktop jars (Compose 1.10.0 + 1.12.0), asserts SubpixelAntiAlias on Windows, cache stability, runtime opt-out, non-Windows delegation
  • LcdTextTest — surface-props gating (transparent/off-Windows/ClearType-off ⇒ null) + pixel-level chromatic-fringe assertion
  • LcdTextCaptureTest — grayscale vs ClearType comparison PNG
  • :examples:cmp-demo:dependencies resolves (Android + Hot Reload guards)
  • :examples:jewel-demo:createDistributable ships the patched ui-text-desktop-*-nucleus-lcd.jar
  • ktlint / detekt / apiCheck green on all touched modules; Windows natives rebuilt

@kdroidFilter

Copy link
Copy Markdown
Collaborator Author

@takke Excuse me, could you review this PR? Windows 10 is still widely used, and I think it's essential, but I had to modify the bytecode. I'm not sure if it's worth it. I don't want any reflections at all in Nucleus, so I didn't really have a choice.

@takke

takke commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Up front: I don't have a Windows 10 machine at hand and font rasterization isn't my area of expertise, so I delegated this review to Claude (Fable 5) — including the design question of whether the bytecode patch is justified. The review below is Claude's; the verification runs were done on my Windows 11 machine (ClearType on, RGB), and I've read through and agree with the conclusions.

Verdict on "was the bytecode patch worth it?"

Yes — and I'd argue this is the right shape for it, not a compromise:

  • There is no runtime hook to use instead. I checked the real ui-text-desktop artifact: PlatformDefault has no CompositionLocal, no system property, nothing. The obvious no-bytecode alternative — providing a default PlatformParagraphStyle through the theme — is leaky: BasicText doesn't merge LocalTextStyle, and TextMeasurer / ParagraphIntrinsics users and third-party widgets that build a TextStyle from scratch all bypass it. FontRasterizationSettings.Companion.getPlatformDefault() is the single choke point every unspecified paragraph falls back to, and patching the declaration site covers every call site in every module with zero reflection. Republishing a forked ui-text-desktop would be the other option, and it's worse: a per-Compose-version publishing treadmill, while the transform patches whatever version each consumer resolves.
  • The classic failure mode of bytecode patching is defused. The risk isn't "patching is scary", it's "a dependency bump silently breaks it". Here a Compose layout change fails the build with an actionable message instead of a NoSuchMethodError at first text layout, and the regression test patches the real artifacts for both the plugin's Compose version and the consumer version parsed from the root catalog — which is exactly the right canary, since a catalog bump is when drift appears. Plus independent build-time and runtime opt-outs.
  • The layering is right. The patched getter only expresses the preference; whether LCD actually happens is decided per surface from the live OS query and alpha mode. So ClearType-off machines (including typical RDP sessions) degrade to grayscale correctly, and if CMP-5359 ever gets fixed upstream the patch stays behaviorally correct and can be retired at leisure rather than urgently.

Code-level checks (all clean)

  • Traced the generated wrapper instruction by instruction: stack discipline is correct, "false".equals(getProperty(...)) is null-safe, the volatile cache race is benign (idempotent value), and the getCommonSuperClass override is safe here since the only merge joins identical types. Renaming + delegating preserves non-Windows behavior exactly.
  • Surface half: all five per-pixel-alpha call sites explicitly pass windowTransparent = true, and making the parameter mandatory is the right call — a future surface call site gets a compile error instead of shipping fringed text. Re-evaluating fullyTransparent || transparentBackgroundState.value per frame correctly covers runtime Mica/Acrylic arming, and the native query's fallbacks are conservative in the right direction (unknown → grayscale, never assume RGB).
  • AWT-backend interaction: the patch applies classpath-wide, so AWT-backend apps also request SubpixelAntiAlias — but skiko-awt's SkiaLayer defaults pixelGeometry to UNKNOWN (verified in the class files) and Compose never sets it, so those surfaces keep unknown geometry and Skia falls back to grayscale. No regression, and it leaves a clean path for the AWT backend to opt in later.

Verified on a real machine (Windows 11)

  • LcdTextDefaultTransformTest: 4/4 against the real jars; decorated-window-tao LCD tests all pass, and the capture test's comparison PNG shows correct RGB fringing.
  • Live end-to-end: ran examples:nucleus-demo (Tao) from this branch and analyzed the window pixels. With the patch active, the tab-label text contains ~500 blue-dominant pixels (B > R) — impossible when blending neutral gray text over the demo's warm background, i.e. definitive subpixel output. Relaunched with -Dnucleus.text.lcd=false: zero such pixels. Both halves and the runtime opt-out work end to end.
  • The SPI values the native query reads match my machine's actual settings (smoothing=1, type=2 ClearType, orientation=1 RGB).

On "Windows 10 is essential": agreed — the gain is largest at 96-DPI / 100% scaling, which is exactly where the Windows 10 install base skews; on high-DPI displays the difference shrinks. This is a real fidelity gap Nucleus is well placed to close.

Non-blocking notes

  1. The geometry cache is process-lifetime, so toggling ClearType (or a console ↔ RDP switch) mid-run keeps stale rendering until restart — native apps track WM_SETTINGCHANGE. Fine as a known limitation; maybe worth a follow-up issue.
  2. LcdTextCaptureTest's label says "Grayscale (avant — #875)" — leftover French "avant" → "before".
  3. LcdTextDefaultTransformTest mutates the global os.name property; it would flake if plugin tests ever run in parallel in one JVM. Fine today.
  4. Worth one line in the docs: Compose Hot Reload dev classpaths are excluded, so dev runs show grayscale while run/packaged builds show ClearType.

@kdroidFilter

Copy link
Copy Markdown
Collaborator Author

Thanks, yes, that seems correct to me too, but I can't merge something like that without checking end-to-end first; I need to install a VM.

Compose hardcodes grayscale as the Windows font-smoothing default. Enable
ClearType end to end without runtime reflection:

- plugin: LcdTextDefaultTransform (artifact transform + ASM) patches
  FontRasterizationSettings.PlatformDefault in ui-text-desktop jars on
  non-test runtime classpaths — SubpixelAntiAlias on Windows, opt-outs
  -Dnucleus.text.lcd=false (runtime) / -Pnucleus.text.lcd.patch=false
  (build). Android/HotReload/KMP guards mirror the CleanNativeLibs
  transform; referenced ctor/enum fields are verified so Compose layout
  drift fails the build. Canary test patches both the plugin's Compose
  and the consumer version from the root version catalog.
- tao: lcdSurfaceProps attaches the OS-queried pixel geometry (cached,
  RGB/BGR, grayscale on any unknown) to opaque Windows window surfaces
  only; per-pixel-alpha surfaces (popups, NativeView overlay, Mica or
  Acrylic backdrops, transparent windows) keep unknown geometry so Skia
  falls back to grayscale. renderGlFrame now requires windowTransparent.
- jewel-demo: use JewelDecoratedWindow instead of hand-rolled theming.
@kdroidFilter
kdroidFilter force-pushed the feat/tao-lcd-cleartype-text branch from 598300b to a72259c Compare August 31, 2026 13:21
@kdroidFilter
kdroidFilter merged commit 33caff6 into nucleus-2.6 Sep 1, 2026
22 of 24 checks passed
kdroidFilter added a commit that referenced this pull request Sep 1, 2026
`LcdTextTest > Compose LCD text on an RGB surface has chromatic edges` has
been failing the macOS tao-tests job since #626 merged, which leaves every PR
targeting this branch red.

Skia can only fringe where the platform font host produces subpixel glyph
masks: DirectWrite and FreeType do, CoreText does not — macOS dropped
subpixel antialiasing in Mojave and renders grayscale whatever the surface's
PixelGeometry says. So `lcdScore == grayScore` there, which is this feature's
documented behaviour (`macOS and Linux stay grayscale` asserts the same thing
on the surface-props side) rather than a regression. Skip the pixel assertion
on macOS only; Windows and Linux keep it.
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