From 69b2fc0457aa81057ffb0dd478e18f2682c5395e Mon Sep 17 00:00:00 2001 From: Will Faust Date: Thu, 1 Oct 2026 20:11:41 +0800 Subject: [PATCH] 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 --- app/Madeira.xcodeproj/project.pbxproj | 4 ++ app/Madeira/AmbientGlow.metal | 17 +++++++ app/Madeira/ConfigCatalog.generated.swift | 3 +- app/Madeira/GlassSkin.swift | 54 ++++++++++++++++++++--- app/Madeira/Library.swift | 40 ++++++++++++++--- app/Madeira/LiquidMetal.metal | 4 +- app/Madeira/LiquidMetalSkin.metal | 4 +- 7 files changed, 107 insertions(+), 19 deletions(-) create mode 100644 app/Madeira/AmbientGlow.metal diff --git a/app/Madeira.xcodeproj/project.pbxproj b/app/Madeira.xcodeproj/project.pbxproj index e686198..c90d0d1 100644 --- a/app/Madeira.xcodeproj/project.pbxproj +++ b/app/Madeira.xcodeproj/project.pbxproj @@ -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 = ""; }; A2000902 /* GlassSkin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GlassSkin.swift"; sourceTree = ""; }; A2000903 /* LiquidMetalSkin.metal */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.metal; path = "LiquidMetalSkin.metal"; sourceTree = ""; }; + A2000904 /* AmbientGlow.metal */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.metal; path = "AmbientGlow.metal"; sourceTree = ""; }; A2000601 /* SteamOwnedLibrary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SteamOwnedLibrary.swift"; sourceTree = ""; }; A2000602 /* SteamDownloadBackground.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SteamDownloadBackground.swift"; sourceTree = ""; }; A2000600 /* SteamInstall.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SteamInstall.swift"; sourceTree = ""; }; @@ -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 */, diff --git a/app/Madeira/AmbientGlow.metal b/app/Madeira/AmbientGlow.metal new file mode 100644 index 0000000..0d342c9 --- /dev/null +++ b/app/Madeira/AmbientGlow.metal @@ -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 +#include +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); +} diff --git a/app/Madeira/ConfigCatalog.generated.swift b/app/Madeira/ConfigCatalog.generated.swift index c45deec..b7b98d4 100644 --- a/app/Madeira/ConfigCatalog.generated.swift +++ b/app/Madeira/ConfigCatalog.generated.swift @@ -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"]), diff --git a/app/Madeira/GlassSkin.swift b/app/Madeira/GlassSkin.swift index 9426293..a29a7dd 100644 --- a/app/Madeira/GlassSkin.swift +++ b/app/Madeira/GlassSkin.swift @@ -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)) } diff --git a/app/Madeira/Library.swift b/app/Madeira/Library.swift index 724302d..f8bdbf4 100644 --- a/app/Madeira/Library.swift +++ b/app/Madeira/Library.swift @@ -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)) } diff --git a/app/Madeira/LiquidMetal.metal b/app/Madeira/LiquidMetal.metal index 441e4b1..a77df7b 100644 --- a/app/Madeira/LiquidMetal.metal +++ b/app/Madeira/LiquidMetal.metal @@ -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)); } diff --git a/app/Madeira/LiquidMetalSkin.metal b/app/Madeira/LiquidMetalSkin.metal index fe1bb35..58fe357 100644 --- a/app/Madeira/LiquidMetalSkin.metal +++ b/app/Madeira/LiquidMetalSkin.metal @@ -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