From 7ee0ec70ac74fe368497619ce36f42e780051596 Mon Sep 17 00:00:00 2001 From: ReffWu Date: Fri, 11 Sep 2026 15:38:54 -0400 Subject: [PATCH] Crop the top and blur by distance as the lid closes Two new Look settings, both on by default, make the fold behave more like a real lid seen from a normal seat. Crop from the top keeps the bottom row at the hinge and samples only the lower cos(0.65 x 90 degrees x progress) of the desktop, so the top slides out of view as the lid closes. The full cosine matches a viewer looking straight at the lid but read as too much stretch from above the screen. Blur by distance sets the blur from each point's distance to where the open screen stood, its height above the hinge times sin(90 degrees x progress), so the hinge stays sharp and the blur keeps growing toward the top until the lid is shut. With it off, the current falloff is kept. --- MOTION.md | 4 ++-- Resources/Fold.metal | 13 +++++++++---- Sources/DesktopRenderer.swift | 7 ++++++- Sources/LiveDesktop.swift | 18 ++++++++++++++++++ Sources/SettingsView.swift | 28 ++++++++++++++++++++++++++++ 5 files changed, 63 insertions(+), 7 deletions(-) diff --git a/MOTION.md b/MOTION.md index 0006b30..4f0525c 100644 --- a/MOTION.md +++ b/MOTION.md @@ -23,9 +23,9 @@ The final portion of that video shows a separate website miniature. Its 72-degre ## Current reconstruction -The new projection keeps the top and bottom edges at their original height. Its homogeneous horizontal taper grows from zero to a maximum top-edge inset of approximately 11.5 percent on each side. This is a visual approximation of the native footage, not a recovered native coefficient. +The new projection keeps the top and bottom edges at their original height. Its homogeneous horizontal taper grows from zero to a maximum top-edge inset of approximately 11.5 percent on each side. This is a visual approximation of the native footage, not a recovered native coefficient. Crop from the top, on by default in Settings, also shortens the desktop toward the hinge the way a closing lid shortens when seen from the front: the bottom row stays at the hinge and the shader samples only the lower cos(0.65 x 90 degrees x progress) of the desktop, so the top slides out of view. The full cosine read as too much stretch from a normal seat above the screen, so the angle is scaled by 0.65. -Three cached Gaussian blur levels at nominal widths of 6, 16, and 36 pixels per 786-pixel reference width provide continuous progressive blur. Blur strength varies with closure and fades toward the lower tenth of the desktop. Subtle top-corner shading and side feathering complete the single effect. The side gaps are filled with the nearest desktop edge, stretched from a broad blur of the reduced frame, or with black, as chosen in Settings. The fill and the desktop are blurred as one surface: every blur level is computed from a composite that places the fill in a side margin around the reduced frame, so blurred content spreads across the fold's edge toward the top while the lower edge stays sharp. Each captured frame adds one scale and one copy into that composite. The blurred fill also runs a quarter-size broad blur and one stretch; the black fill clears the margin instead. On an M1 Max the blurred fill's chain measured about 0.2 ms per captured frame, up from 0.11 ms. An inverse projection in the fragment shader preserves the fold geometry while covering the full overlay. +Three cached Gaussian blur levels at nominal widths of 6, 16, and 36 pixels per 786-pixel reference width provide continuous progressive blur. With Blur by distance, on by default, blur strength follows each point's distance from where the open screen stood: its height above the hinge times sin(90 degrees x progress), so the hinge stays sharp and the top edge of a fully folded screen reaches the 36-pixel level. With it off, blur strength varies with closure and fades toward the lower tenth of the desktop. Subtle top-corner shading and side feathering complete the single effect. The side gaps are filled with the nearest desktop edge, stretched from a broad blur of the reduced frame, or with black, as chosen in Settings. The fill and the desktop are blurred as one surface: every blur level is computed from a composite that places the fill in a side margin around the reduced frame, so blurred content spreads across the fold's edge toward the top while the lower edge stays sharp. Each captured frame adds one scale and one copy into that composite. The blurred fill also runs a quarter-size broad blur and one stretch; the black fill clears the margin instead. On an M1 Max the blurred fill's chain measured about 0.2 ms per captured frame, up from 0.11 ms. An inverse projection in the fragment shader preserves the fold geometry while covering the full overlay. The effect covers the built-in screen's full display frame, including the menu bar and Dock. A non-activating panel joins fullscreen Spaces without taking keyboard focus. Space changes keep the same capture area and bring the panel back to the front. diff --git a/Resources/Fold.metal b/Resources/Fold.metal index 5479c8f..60ffd7a 100644 --- a/Resources/Fold.metal +++ b/Resources/Fold.metal @@ -7,6 +7,8 @@ struct FoldParameters { float blurInset; float blurSpan; float taper; + float crop; + float depth; }; struct FoldVertex { @@ -31,14 +33,17 @@ fragment float4 foldFragment(FoldVertex in [[stage_in]], texture2d sides [[texture(4)]], constant FoldParameters &p [[buffer(0)]]) { constexpr sampler sampleMode(coord::normalized, address::clamp_to_edge, filter::linear); + float turn = p.progress * 1.5707964; float taper = p.taper * p.progress; float q = (1.0 + taper) / (1.0 + taper * in.uv.y); - float2 uv = float2((in.uv.x - 0.5) * q + 0.5, in.uv.y * q); + float2 fold = float2((in.uv.x - 0.5) * q + 0.5, in.uv.y * q); + float2 uv = float2(fold.x, 1.0 - mix(1.0, cos(turn * 0.65), p.crop) * (1.0 - fold.y)); float edge = min(uv.x, 1.0 - uv.x); float2 blurUV = float2(p.blurInset + uv.x * p.blurSpan, uv.y); - float feather = smoothstep(0.0, max(0.0001, p.progress * 0.012 * (1.0 - uv.y)), edge); + float feather = smoothstep(0.0, max(0.0001, p.progress * 0.012 * (1.0 - fold.y)), edge); float3 sharp = mix(sides.sample(sampleMode, blurUV).rgb, source.sample(sampleMode, uv).rgb, feather); - float amount = 36.0 * p.progress * (1.0 - smoothstep(0.0, 0.9, uv.y)); + float falloff = p.progress * (1.0 - smoothstep(0.0, 0.9, fold.y)); + float amount = 36.0 * mix(falloff, sin(turn) * (1.0 - in.uv.y), p.depth); float3 color; if (amount < 6.0) { color = mix(sharp, soft.sample(sampleMode, blurUV).rgb, amount / 6.0); @@ -47,7 +52,7 @@ fragment float4 foldFragment(FoldVertex in [[stage_in]], } else { color = mix(medium.sample(sampleMode, blurUV).rgb, broad.sample(sampleMode, blurUV).rgb, (amount - 16.0) / 20.0); } - float upper = 1.0 - smoothstep(0.0, 0.85, uv.y); + float upper = 1.0 - smoothstep(0.0, 0.85, fold.y); float corners = (1.0 - smoothstep(0.0, 0.19, edge)) * upper; color *= 1.0 - p.progress * (0.50 * corners + 0.10 * upper); return float4(color * p.opacity, p.opacity); diff --git a/Sources/DesktopRenderer.swift b/Sources/DesktopRenderer.swift index 4b805c7..6c1cdf1 100644 --- a/Sources/DesktopRenderer.swift +++ b/Sources/DesktopRenderer.swift @@ -8,6 +8,8 @@ struct FoldParameters { var blurInset: Float = 0 var blurSpan: Float = 1 var taper = DesktopRenderer.taper + var crop: Float = 0 + var depth: Float = 0 } enum SideFill: String { @@ -39,6 +41,8 @@ final class DesktopRenderer: NSObject, MTKViewDelegate { private let motion: LidMotion private var wasPresented = false var effectStrength: Float = 1 + var cropsTop = true + var blursByDistance = true var sideFill = SideFill.blur { didSet { if sideFill != oldValue { blurredGeneration = nil } } } @@ -292,7 +296,8 @@ final class DesktopRenderer: NSObject, MTKViewDelegate { let paddedWidth = Float(sideTexture?.width ?? 1) var parameters = FoldParameters( progress: progress, opacity: opacity, blurInset: Float(blurPadding) / paddedWidth, - blurSpan: (paddedWidth - Float(2 * blurPadding)) / paddedWidth) + blurSpan: (paddedWidth - Float(2 * blurPadding)) / paddedWidth, crop: cropsTop ? 1 : 0, + depth: blursByDistance ? 1 : 0) encoder.setRenderPipelineState(pipeline) encoder.setVertexBytes(¶meters, length: MemoryLayout.stride, index: 0) encoder.setFragmentBytes(¶meters, length: MemoryLayout.stride, index: 0) diff --git a/Sources/LiveDesktop.swift b/Sources/LiveDesktop.swift index 3466320..de02906 100644 --- a/Sources/LiveDesktop.swift +++ b/Sources/LiveDesktop.swift @@ -36,6 +36,8 @@ final class LiveDesktop: NSObject, ObservableObject { @Published private(set) var openAngle: Double @Published private(set) var effectStrength: Double @Published private(set) var sideFill: SideFill + @Published private(set) var cropsTop: Bool + @Published private(set) var blursByDistance: Bool @Published private(set) var error: String? @Published private(set) var needsPermission = false @Published private(set) var isEnabled = UserDefaults.standard.bool(forKey: "effectEnabled") @@ -70,6 +72,8 @@ final class LiveDesktop: NSObject, ObservableObject { self.openAngle = openAngle self.effectStrength = effectStrength sideFill = UserDefaults.standard.string(forKey: "sideFill").flatMap(SideFill.init) ?? .blur + cropsTop = UserDefaults.standard.object(forKey: "cropsTop") as? Bool ?? true + blursByDistance = UserDefaults.standard.object(forKey: "blursByDistance") as? Bool ?? true motion = LidMotion(openAngle: openAngle) super.init() let motion = motion @@ -174,6 +178,18 @@ final class LiveDesktop: NSObject, ObservableObject { renderer?.sideFill = fill } + func setCropsTop(_ enabled: Bool) { + cropsTop = enabled + UserDefaults.standard.set(enabled, forKey: "cropsTop") + renderer?.cropsTop = enabled + } + + func setBlursByDistance(_ enabled: Bool) { + blursByDistance = enabled + UserDefaults.standard.set(enabled, forKey: "blursByDistance") + renderer?.blursByDistance = enabled + } + func start(promptForPermission: Bool = true) async { guard !isStarting, !isActive else { return } error = nil @@ -198,6 +214,8 @@ final class LiveDesktop: NSObject, ObservableObject { let renderer = try DesktopRenderer(resources: .main, motion: motion) renderer.effectStrength = Float(effectStrength) renderer.sideFill = sideFill + renderer.cropsTop = cropsTop + renderer.blursByDistance = blursByDistance let content = try await SCShareableContent.excludingDesktopWindows( false, onScreenWindowsOnly: false) guard self.session == session else { return } diff --git a/Sources/SettingsView.swift b/Sources/SettingsView.swift index dc7d1d6..e146926 100644 --- a/Sources/SettingsView.swift +++ b/Sources/SettingsView.swift @@ -60,6 +60,34 @@ struct SettingsView: View { .fixedSize() .controlSize(.small) } + SettingsDivider() + SettingsRow( + "crop", tint: .teal, title: "Crop from the top", + subtitle: "The top of the desktop slides out of view as the lid closes." + ) { + Toggle( + "Crop from the top", + isOn: Binding(get: { desktop.cropsTop }, set: { desktop.setCropsTop($0) }) + ) + .toggleStyle(.switch) + .controlSize(.small) + .labelsHidden() + } + SettingsDivider() + SettingsRow( + "camera.aperture", tint: .purple, title: "Blur by distance", + subtitle: desktop.blursByDistance + ? "Blur grows with distance from the open screen, so the hinge edge stays sharp." + : "Blur builds toward the top and fades out near the hinge." + ) { + Toggle( + "Blur by distance", + isOn: Binding(get: { desktop.blursByDistance }, set: { desktop.setBlursByDistance($0) }) + ) + .toggleStyle(.switch) + .controlSize(.small) + .labelsHidden() + } } }