Library: liquid metal toggle in Settings, Liquid Glass by default

- Settings > Appearance > Liquid metal turns the app's liquid metal on
  and off at once, fading between it and Liquid Glass both ways; it is
  off by default (env.MADEIRA_LIQUID_METAL = 1 in madeira.cfg turns it
  on) and covers the bars, the lens and search field rims and the
  Desktop button, whose label and fill crossfade.
- In dark mode the metal's highlights roll off to 0.8 of white.
- The library cards' ambient light is tighter, a little dimmer in dark
  mode, and a soft knee (AmbientGlow.metal) keeps very bright artwork
  from glaring.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Will Faust
2026-10-01 20:11:41 +08:00
co-authored by Claude Opus 5.5
parent 79e28f068b
commit 69b2fc0457
7 changed files with 107 additions and 19 deletions
+4
View File
@@ -18,6 +18,7 @@
A1000910 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A2000910 /* Assets.xcassets */; };
A1000902 /* GlassSkin.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000902 /* GlassSkin.swift */; };
A1000903 /* LiquidMetalSkin.metal in Sources */ = {isa = PBXBuildFile; fileRef = A2000903 /* LiquidMetalSkin.metal */; };
A1000904 /* AmbientGlow.metal in Sources */ = {isa = PBXBuildFile; fileRef = A2000904 /* AmbientGlow.metal */; };
A1000601 /* SteamOwnedLibrary.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000601 /* SteamOwnedLibrary.swift */; };
A1000602 /* SteamDownloadBackground.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000602 /* SteamDownloadBackground.swift */; };
A1000600 /* SteamInstall.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000600 /* SteamInstall.swift */; };
@@ -131,6 +132,7 @@
A2000910 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
A2000902 /* GlassSkin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GlassSkin.swift"; sourceTree = "<group>"; };
A2000903 /* LiquidMetalSkin.metal */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.metal; path = "LiquidMetalSkin.metal"; sourceTree = "<group>"; };
A2000904 /* AmbientGlow.metal */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.metal; path = "AmbientGlow.metal"; sourceTree = "<group>"; };
A2000601 /* SteamOwnedLibrary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SteamOwnedLibrary.swift"; sourceTree = "<group>"; };
A2000602 /* SteamDownloadBackground.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SteamDownloadBackground.swift"; sourceTree = "<group>"; };
A2000600 /* SteamInstall.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SteamInstall.swift"; sourceTree = "<group>"; };
@@ -312,6 +314,7 @@
A2000910 /* Assets.xcassets */,
A2000902 /* GlassSkin.swift */,
A2000903 /* LiquidMetalSkin.metal */,
A2000904 /* AmbientGlow.metal */,
A2000601 /* SteamOwnedLibrary.swift */,
A2000602 /* SteamDownloadBackground.swift */,
A2000600 /* SteamInstall.swift */,
@@ -551,6 +554,7 @@
A1000900 /* LiquidMetal.metal in Sources */,
A1000902 /* GlassSkin.swift in Sources */,
A1000903 /* LiquidMetalSkin.metal in Sources */,
A1000904 /* AmbientGlow.metal in Sources */,
A1000601 /* SteamOwnedLibrary.swift in Sources */,
A1000602 /* SteamDownloadBackground.swift in Sources */,
A1000600 /* SteamInstall.swift in Sources */,
+17
View File
@@ -0,0 +1,17 @@
// AmbientGlow.metal — the library's ambient card light (AmbientGlow in Library.swift).
//
// A SwiftUI [[stitchable]] color shader on the glow layer: a soft knee on its brightest
// parts, so a very bright artwork (a neon green cover) throws light as strong as the rest
// instead of glaring, while dark and mid tones pass almost unchanged. `knee` is how much
// the brightest value comes down (0: off).
#include <metal_stdlib>
#include <SwiftUI/SwiftUI_Metal.h>
using namespace metal;
[[ stitchable ]] half4 ambientKnee(float2 position, half4 color, float knee) {
if (color.a <= 0.0h || knee <= 0.0) { return color; }
float3 rgb = float3(color.rgb) / float(color.a);
rgb -= knee * rgb * rgb;
return half4(half3(rgb * float(color.a)), color.a);
}
+1 -2
View File
@@ -26,10 +26,9 @@ extension ConfigCatalog {
ConfigOption(key: "env.MADEIRA_FRONTEND_CONTROLLER", title: "", kind: .bool, defaultValue: "1", category: "App & front end", note: "", choices: [], sources: ["app/Madeira/Library.swift"]),
ConfigOption(key: "env.MADEIRA_FRONTEND_DEFAULT_NEW", title: "", kind: .bool, defaultValue: "1", category: "App & front end", note: "Which interface Madeira starts with: the library (the default) or the developer interface. The choice is made in either interface, stored in UserDefaults and read once per run, so a change applies at the next start. Without a stored choi...", choices: [], sources: ["app/Madeira/Library.swift"]),
ConfigOption(key: "env.MADEIRA_FRONTEND_KEYBOARD", title: "", kind: .bool, defaultValue: "1", category: "App & front end", note: "MADEIRA_FRONTEND_KEYBOARD=0: the game view's own keyboard instead.", choices: [], sources: ["app/Madeira/Library.swift"]),
ConfigOption(key: "env.MADEIRA_GLASS_SKIN", title: "", kind: .bool, defaultValue: "1", category: "App & front end", note: "makes SwiftUI start a morph back to a wider item set from a far wider pill (for about 0.2 s), which stock glass never does; the skin shows it. Placing the skin outside SwiftUI's tree avoids that but gets drawn over dark by the pill's own...", choices: [], sources: ["app/Madeira/GlassSkin.swift"]),
ConfigOption(key: "env.MADEIRA_LIBRARY_AMBIENT", title: "", kind: .bool, defaultValue: "1", category: "App & front end", note: "The library's ambient light: a glow behind every grid card that has reported its frame (only the cards the lazy grids have built). MADEIRA_LIBRARY_AMBIENT=0 turns it off.", choices: [], sources: ["app/Madeira/Library.swift"]),
ConfigOption(key: "env.MADEIRA_LIBRARY_HIDE_ENDED_DESKTOP", title: "", kind: .bool, defaultValue: "1", category: "App & front end", note: "", choices: [], sources: ["app/Madeira/Library.swift"]),
ConfigOption(key: "env.MADEIRA_LIQUID_METAL", title: "", kind: .bool, defaultValue: "1", category: "App & front end", note: "A flowing liquid-chrome fill (LiquidMetal.metal, a SwiftUI color shader): white highlights, silver and navy, rainbow dispersion at the highlights' edges and a raised rim, in a capsule (a rounded box of radius half its height). Animated a...", choices: [], sources: ["app/Madeira/Library.swift"]),
ConfigOption(key: "env.MADEIRA_LIQUID_METAL", title: "", kind: .bool, defaultValue: "0", category: "App & front end", note: "Whether the app's liquid metal is on: Settings › Appearance › Liquid metal, kept in madeira.cfg as env.MADEIRA_LIQUID_METAL (off, plain Liquid Glass, unless it is 1). It covers the Desktop button's fill (LiquidMetalFill) and the bars' gl...", choices: [], sources: ["app/Madeira/Library.swift"]),
ConfigOption(key: "env.MADEIRA_LOG_VIA_STDERR", title: "", kind: .bool, defaultValue: "1", category: "App & front end", note: "ml359: also persist — jit_log lines (incl. the [no-footprint] verdict) previously reached only the UI view, which dies with the app; pulled logs never contained them.", choices: [], sources: ["app/Madeira/LogStore.swift"]),
ConfigOption(key: "env.MADEIRA_ONE_SESSION_PER_RUN", title: "", kind: .bool, defaultValue: "1", category: "App & front end", note: "One Wine session per app run (see LibraryModel.sessionsThisRun).", choices: [], sources: ["app/Madeira/ContentView.swift"]),
ConfigOption(key: "env.MADEIRA_PROMOTE", title: "Hold the display at its maximum rate", kind: .bool, defaultValue: "0", category: "App & front end", note: "The opt-in above; read when a session's pacing is applied.", choices: [], sources: ["app/Madeira/FPSOverlay.swift"]),
+48 -6
View File
@@ -34,11 +34,13 @@ import UIKit
/// like a pill, and the lens that lifts out of the selected tab under a finger keeps its
/// glass with metal only around its edge, dissolving into the glass toward the middle
/// (`rim`). The navigation bar's search field gets the same rim. Everything is found by class name and key-value coding; if any of it is
/// missing the glass stays as it is. MADEIRA_GLASS_SKIN=0 turns it off.
/// missing the glass stays as it is. Settings › Appearance › Liquid metal (LiquidMetalSetting,
/// env.MADEIRA_LIQUID_METAL in madeira.cfg) turns it on and off at once, with the Desktop
/// button's liquid metal: off, the app shows plain Liquid Glass.
@MainActor
final class GlassSkin: NSObject {
static let shared = GlassSkin()
static let enabled = MadeiraConfig.flag("MADEIRA_GLASS_SKIN")
static var enabled: Bool { LiquidMetalSetting.shared.on }
private static let groupName = "madeira.glass-skin"
private var observer: CFRunLoopObserver?
@@ -65,8 +67,11 @@ final class GlassSkin: NSObject {
let veils: Bool
}
func start() {
/// `fadeIn`: liquid metal was just turned on in Settings, so the pills it finds in the
/// next moment fade from their glass into metal instead of switching.
func start(fadeIn: Bool = false) {
guard Self.enabled, observer == nil, Self.privateClassesPresent, preparePipeline() else { return }
fadeInUntil = fadeIn ? CACurrentMediaTime() + 0.5 : 0
// Core Animation commits in an observer of order 2,000,000; run just before it.
observer = CFRunLoopObserverCreateWithHandler(nil, CFRunLoopActivity.beforeWaiting.rawValue | CFRunLoopActivity.exit.rawValue,
true, 1_999_999) { _, _ in
@@ -82,6 +87,32 @@ final class GlassSkin: NSObject {
LogStore.shared.log("[glass-skin] started")
}
/// Takes the skin off every pill (liquid metal turned off in Settings): the metal fades
/// out over the system's glass, and nothing more is drawn or attached.
func stop() {
guard let observer else { return }
CFRunLoopRemoveObserver(CFRunLoopGetMain(), observer, .commonModes)
self.observer = nil
link?.invalidate()
link = nil
let fading = skins
CATransaction.begin()
CATransaction.setAnimationDuration(UIAccessibility.isReduceMotionEnabled ? 0.15 : 0.35)
CATransaction.setCompletionBlock { MainActor.assumeIsolated { for skin in fading { skin.group.removeFromSuperlayer() } } }
for skin in fading {
skin.group.opacity = 0
if !skin.rim, let backdrop = skin.container?.sublayers?.first(where: { Self.isClass($0, "CABackdropLayer") }) {
backdrop.opacity = 1
}
}
CATransaction.commit()
skins.removeAll()
steadyRects.removeAll()
bars.removeAll()
veiled = false
LogStore.shared.log("[glass-skin] stopped")
}
/// SwiftUI updates a pill's layers during Core Animation's commit itself (its hosting
/// view lays out and renders there), after the run-loop observer: when a morph settles
/// it swaps the shape's element source and resets the backdrop that way, and one frame
@@ -96,9 +127,10 @@ final class GlassSkin: NSObject {
return unsafeBitCast(method_getImplementation(method), to: AddCommitHandler.self)
}()
private var commitHandlerPending = false
private var fadeInUntil: CFTimeInterval = 0
private func attachAgainAfterLayout() {
guard !commitHandlerPending, let add = Self.addCommitHandler else { return }
guard observer != nil, !commitHandlerPending, let add = Self.addCommitHandler else { return }
commitHandlerPending = true
add(CATransaction.self, Self.addCommitHandlerSelector, {
MainActor.assumeIsolated {
@@ -113,6 +145,7 @@ final class GlassSkin: NSObject {
}
private func preparePipeline() -> Bool {
if pipeline != nil { return true }
guard let device = MTLCreateSystemDefaultDevice(), let library = device.makeDefaultLibrary(),
let vertex = library.makeFunction(name: "liquidMetalSkinVertex"),
let fragment = library.makeFunction(name: "liquidMetalSkinFragment") else { return false }
@@ -130,6 +163,7 @@ final class GlassSkin: NSObject {
// MARK: Attaching
private func attach() {
guard observer != nil else { return } // stopped (a commit handler may still fire)
// The bars persist; a navigation bar's platter containers do not (opening and
// closing a bar button's menu builds a new one), so the glass is looked up under the
// bars on every pass (a walk of a few dozen layers) and only the bars are cached.
@@ -181,8 +215,13 @@ final class GlassSkin: NSObject {
skin.group.opacity = on ? 0 : 1
backdrop.opacity = on ? 1 : 0
CATransaction.commit()
guard !on else { return }
for (layer, from, to) in [(skin.group, Float(0), Float(1)), (backdrop, Float(1), Float(0))] {
if !on { fadeIn(skin, over: backdrop) }
}
/// The metal fades in over the system's glass (whose backdrop fades out under it).
private func fadeIn(_ skin: Skin, over backdrop: CALayer?) {
let pairs: [(CALayer, Float, Float)] = [(skin.group, 0, 1)] + (backdrop.map { [($0, Float(1), Float(0))] } ?? [])
for (layer, from, to) in pairs {
let fade = CABasicAnimation(keyPath: "opacity")
fade.fromValue = from
fade.toValue = to
@@ -358,6 +397,9 @@ final class GlassSkin: NSObject {
backdrop.opacity = 0
}
skins.append(Skin(container: container, source: source, group: group, metal: metal, rim: rim, lens: lens, veils: veils))
if CACurrentMediaTime() < fadeInUntil, !(veils && veiled) {
fadeIn(skins[skins.count - 1], over: rim ? nil : backdrop)
}
render(skins[skins.count - 1], light: container.delegate.flatMap { ($0 as? UIView)?.traitCollection.userInterfaceStyle == .light }
?? (bars.lazy.compactMap(\.view).first?.traitCollection.userInterfaceStyle == .light))
}
+33 -7
View File
@@ -1419,7 +1419,7 @@ struct AmbientGlow: View {
// A soft wash on the wall, and the brighter band just past the edge where the
// strip's light lands first.
return ZStack {
art().scaleEffect(1.11).blur(radius: size.width * 0.085)
art().scaleEffect(1.09).blur(radius: size.width * 0.07)
art().scaleEffect(1.03).blur(radius: size.width * 0.03).opacity(0.75)
}
.frame(width: frame.width, height: frame.height)
@@ -1432,12 +1432,12 @@ struct AmbientGlow: View {
var body: some View {
// Kept tight, so neighbouring cards' light stays apart in the gaps between them.
let spread = size.width * 0.20
let spread = size.width * 0.17
let frame = CGSize(width: size.width + spread * 2, height: size.height + spread * 2)
let movie = AmbientMovie(seed: seed)
let dark = scheme == .dark
// A game that is not installed throws a fainter, less vivid light.
let strength = (dark ? 1.0 : 0.8) * (dimmed ? 0.3 : 1)
let strength = (dark ? 0.92 : 0.8) * (dimmed ? 0.3 : 1)
let plain = layer(0, false, frame: frame), turned = layer(180, false, frame: frame), mirrored = layer(0, true, frame: frame)
TimelineView(.animation(minimumInterval: 1.0 / 30, paused: reduceMotion)) { context in
let t = context.date.timeIntervalSinceReferenceDate
@@ -1449,6 +1449,9 @@ struct AmbientGlow: View {
}
.compositingGroup()
.hueRotation(.degrees(f.hue))
// In the dark the light adds to the page (plusLighter): a very bright artwork's
// light is brought down at the top (AmbientGlow.metal) so it does not glare.
.colorEffect(ShaderLibrary.ambientKnee(.float(dark ? 0.3 : 0)))
.mask { AmbientMovie.mask(f.light) }
.mask {
let turn = reduceMotion ? 0 : 1.2 * sin(t * 0.12 + Double(seed % 97))
@@ -1572,13 +1575,27 @@ enum AmbientArtwork {
}
}
/// Whether the app's liquid metal is on: Settings › Appearance › Liquid metal, kept in
/// madeira.cfg as env.MADEIRA_LIQUID_METAL (off, plain Liquid Glass, unless it is 1). It
/// covers the Desktop button's fill (LiquidMetalFill) and the bars' glass (GlassSkin), and
/// applies at once, fading between the two.
@MainActor final class LiquidMetalSetting: ObservableObject {
static let shared = LiquidMetalSetting()
@Published var on = MadeiraConfig.flag("MADEIRA_LIQUID_METAL", fallback: false) {
didSet {
guard on != oldValue else { return }
MadeiraConfig.set("env.MADEIRA_LIQUID_METAL", on ? "1" : nil)
if on { GlassSkin.shared.start(fadeIn: true) } else { GlassSkin.shared.stop() }
}
}
}
/// A flowing liquid-chrome fill (LiquidMetal.metal, a SwiftUI color shader): white
/// highlights, silver and navy, rainbow dispersion at the highlights' edges and a raised
/// rim, in a capsule (a rounded box of radius half its height). Animated at 60 frames a
/// second; held still with Reduce Motion. MADEIRA_LIQUID_METAL=0 turns it off, and the
/// callers draw their previous fill.
/// second; held still with Reduce Motion. Its callers draw their previous fill when liquid
/// metal is off (LiquidMetalSetting).
struct LiquidMetalFill: View {
static let enabled = MadeiraConfig.flag("MADEIRA_LIQUID_METAL")
@Environment(\.displayScale) private var displayScale
@Environment(\.colorScheme) private var scheme
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@@ -1764,6 +1781,7 @@ struct LibraryView: View {
@ObservedObject private var model = LibraryModel.shared
@Environment(\.scenePhase) private var scenePhase
@Environment(\.colorScheme) private var colorScheme
@ObservedObject private var liquidMetal = LiquidMetalSetting.shared
var play: (LibraryEntry) -> Void
var enableJIT: () -> Void
@ObservedObject private var jitState = LibraryJITState.shared
@@ -1908,6 +1926,13 @@ struct LibraryView: View {
if SteamSettingsSection.shown, settingsShow("Steam", "Dock", "sign in", "account", "setup") {
SteamSettingsSection(open: { settingsSheet = $0 })
}
if settingsShow("appearance", "liquid metal", "metal", "glass") {
Section {
Toggle("Liquid metal", isOn: $liquidMetal.on)
} header: { Text("Appearance") } footer: {
Text("Flowing chrome on the bars and the Desktop button. Off, they use the system's Liquid Glass.")
}
}
if settingsShow("interface", "developer") {
Section {
Toggle("Use developer interface", isOn: Binding(get: { developerUI }, set: { on in
@@ -1955,7 +1980,7 @@ struct LibraryView: View {
VStack(alignment: .leading, spacing: 24) {
HStack {
Button { selected = model.entries.first(where: { $0.desktop == true }) ?? .desktopEntry } label: {
if LiquidMetalFill.enabled {
if liquidMetal.on {
// On the chrome's middle band (dark, or light in light mode), with a soft
// halo of the other tone for the moments a highlight passes under it.
let light = colorScheme == .light
@@ -1970,6 +1995,7 @@ struct LibraryView: View {
.background(Color(uiColor: .secondarySystemGroupedBackground), in: Capsule())
}
}.buttonStyle(.plain)
.animation(UIAccessibility.isReduceMotionEnabled ? nil : .easeInOut(duration: 0.4), value: liquidMetal.on)
.id(LibraryEntry.desktopID)
.overlay(RoundedRectangle(cornerRadius: 22).stroke(focused == LibraryEntry.desktopID && controller.connected ? Color.cyan : .clear, lineWidth: 3))
}
+2 -2
View File
@@ -157,8 +157,8 @@ constant float3 spectrumSum = float3(3.40, 2.75, 2.55);
rgb = mix(rgb, float3(1.0), line * 0.75);
rgb = clamp(rgb, 0.0, 1.0);
// In dark mode the highlights roll off to 0.9 (the darks are left alone); in light
// In dark mode the highlights roll off to 0.8 (the darks are left alone); in light
// mode they stay full white, like the page around them.
if (light < 0.5) { rgb -= 0.1 * rgb * rgb; }
if (light < 0.5) { rgb -= 0.2 * rgb * rgb; }
return half4(half3(rgb * alpha), half(alpha));
}
+2 -2
View File
@@ -158,9 +158,9 @@ fragment half4 liquidMetalSkinFragment(liquidmetalskin::VertexOut in [[stage_in]
rgb = navy + (1.0 - navy) * clamp(rgb, 0.0, 1.0);
rgb += float3(-0.03, 0.0, 0.07) * (lum * (1.0 - lum) * 4.0);
rgb = clamp(rgb, 0.0, 1.0);
// In dark mode the highlights roll off to 0.9 (the darks are left alone); in light
// In dark mode the highlights roll off to 0.8 (the darks are left alone); in light
// mode they stay full white, like the page around them.
if (u.light < 0.5) { rgb -= 0.1 * rgb * rgb; }
if (u.light < 0.5) { rgb -= 0.2 * rgb * rgb; }
// The lens: full metal at the edge, gone a rim's width in. The boundary flows with
// its own finer, faster folds, so the metal seems to melt into the glass. It grows in