diff --git a/MOTION.md b/MOTION.md index 6dd0cea..fee65a8 100644 --- a/MOTION.md +++ b/MOTION.md @@ -31,7 +31,7 @@ The effect covers the built-in screen's usable desktop area, excluding the norma ## Motion timing -The default open position is 100 degrees. The calibration button captures a comfortable viewing angle and saves it across launches. Enabling the effect does not overwrite the chosen position. The baseline stays fixed while the user holds the lid partly closed and is retained across sleep and capture reinitialization. +The default open position is 100 degrees. The calibration button captures a comfortable viewing angle and saves it across launches. Enabling the effect does not overwrite the chosen position. The baseline stays fixed while the user holds the lid partly closed and is retained across sleep and capture reinitialization. Following the open angle is optional and on by default. The baseline then moves to whatever angle the lid settles at, once a reading holds within 1.5 degrees for 750 ms and the lid sits at least three degrees above its lowest recent reading. That travel requirement keeps a pause during a close from being read as a new open position, which a single degree threshold could not do because adjacent-degree sensor noise alone would satisfy it. Adoption changes the baseline without resetting the filter, so the damped response glides the fold to rest instead of snapping. Angles below 25 degrees are ignored, matching the manual calibration guard. The input is a stream of integer-degree readings. A 0.6-degree noise band prevents alternating adjacent readings from constantly moving the target. Angular velocity is estimated with a 60 ms time constant. Prediction looks ahead by 35 ms and is limited to 0.75 degrees. Closure is mapped from the calibrated baseline toward eight degrees. @@ -41,7 +41,7 @@ There is no fixed playback timeline or additional SwiftUI animation in the motio The sensor reads feature reports on a dedicated queue at 120 Hz while the effect is enabled and 10 Hz while disabled. It does not wait for input notifications, which did not track physical movement reliably. Repeated readings still advance the estimator clock; consecutive failed reads clear the effect instead of leaving an old position on screen. A view-owned display link schedules drawing at a steady 60 Hz, passing the expected presentation timestamp to the motion estimator. MTKView remains in explicit-draw mode so only that display link controls cadence. Captured content arrives separately at up to 60 fps. The selected draw cadence avoids repeated drawable stalls observed when requesting 120 Hz. A synthetic run measured 16.67 ms average frame spacing and 16.79 ms at the 95th percentile, with a first motion draw taking 1.10 ms on the CPU. These are local rendering measurements, not sensor-to-display latency. Each newly captured frame generates cached GPU blur levels; lid movement only changes the final projection and blur blend. The renderer reuses the latest content and does not wait for a new capture frame to move. -The previous 12 ms first-order filter followed individual degree changes too closely. High rendering frame rates did not eliminate the visible stair-step input. The current motion filter smooths position and velocity together and suppresses quantization jitter. A synthetic replay checks slow and fast closure at 30, 60, and 120 input samples per second, held adjacent-degree noise, and reopening. +The previous 12 ms first-order filter followed individual degree changes too closely. High rendering frame rates did not eliminate the visible stair-step input. The current motion filter smooths position and velocity together and suppresses quantization jitter. A synthetic replay checks slow and fast closure at 30, 60, and 120 input samples per second, held adjacent-degree noise, and reopening. A second replay covers open angle adoption: a jittering hold partway through a close adopts nothing, reparking the lid adopts the new angle, and the fold then unwinds through the damped response rather than snapping. Before On appears, an offscreen GPU pass initializes the blur textures, Gaussian kernels, and fold pipeline, and capture supplies its first frame. A transparent window stays ordered at rest with drawing paused, so closing does not need to allocate a new window surface. The first 2.5 percent of closure smoothly blends the captured image into the live desktop. At full reopening, a transparent frame is presented before drawing pauses. diff --git a/README.md b/README.md index 01cc68e..4c758ce 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ make build open build/Hinge.app ``` -Allow Screen Recording, reopen Hinge if prompted, and turn it on. It turns itself back on the next time you open it. The starting angle is 100°. Prefer something else? Get comfy and click **Set open position**. Hinge remembers. +Allow Screen Recording, reopen Hinge if prompted, and turn it on. It turns itself back on the next time you open it. By default Hinge treats whatever angle you settle at as your open position, so wherever you park the lid stays clear and only closing from there folds. Prefer one fixed angle? Turn off **Follow my open angle**, get comfy, and click **Set open position**. Hinge remembers. ## Got an idea? diff --git a/Sources/HingeApp.swift b/Sources/HingeApp.swift index fefbd92..6fd6989 100644 --- a/Sources/HingeApp.swift +++ b/Sources/HingeApp.swift @@ -111,7 +111,7 @@ struct HingeMenu: View { } .disabled(desktop.isStarting) Button("Set open position") { desktop.setOpenPosition() } - .disabled(!desktop.sensorAvailable || desktop.isStarting) + .disabled(!desktop.sensorAvailable || desktop.isStarting || desktop.followOpenAngle) Divider() Button("Open Hinge") { navigator.screen = .main diff --git a/Sources/LidMotion.swift b/Sources/LidMotion.swift index a527cd7..e51abc2 100644 --- a/Sources/LidMotion.swift +++ b/Sources/LidMotion.swift @@ -14,15 +14,21 @@ final class LidMotion { private var displayVelocity = 0.0 private var lastFrame = 0.0 private var lastSample = 0.0 + private var following = false + private var lowestAngle: Double? + private var settleAngle: Double? + private var settleStart = 0.0 - init(openAngle: Double = 100) { + init(openAngle: Double = 100, followOpenAngle: Bool = false) { baseline = openAngle + following = followOpenAngle } struct Update { let availabilityChanged: Bool let available: Bool let beganClosing: Bool + let adoptedAngle: Double? } func receive(_ value: Double?, at time: Double = CACurrentMediaTime()) -> Update { @@ -31,6 +37,11 @@ final class LidMotion { let changed = (angle == nil) != (value == nil) let previous = target angle = value + if let value { + lowestAngle = min(lowestAngle ?? value, value) + } else { + lowestAngle = nil + } if let value, let trackedAngle, lastSample > 0, time >= lastSample { let delta = max(time - lastSample, 0.001) let nextAngle = min(max(trackedAngle, value - 0.6), value + 0.6) @@ -48,10 +59,39 @@ final class LidMotion { direction = 0 } lastSample = time + let adopted = adoptOpenAngle(value, at: time) updateTarget(at: time) return Update( availabilityChanged: changed, available: value != nil, - beganClosing: previous == 0 && target > 0) + beganClosing: previous == 0 && target > 0, adoptedAngle: adopted) + } + + private func adoptOpenAngle(_ value: Double?, at time: Double) -> Double? { + guard following, let value, let lowest = lowestAngle else { + settleAngle = nil + return nil + } + guard let candidate = settleAngle, abs(value - candidate) <= 1.5 else { + settleAngle = value + settleStart = time + return nil + } + guard value - lowest >= 3, value >= 25, time - settleStart >= 0.75, + abs(value - baseline) > 0.5 + else { return nil } + baseline = value + lowestAngle = value + settleAngle = value + settleStart = time + return value + } + + func setFollowOpenAngle(_ value: Bool) { + lock.lock() + defer { lock.unlock() } + following = value + settleAngle = nil + lowestAngle = angle } @discardableResult @@ -70,6 +110,7 @@ final class LidMotion { enabled = value reset() updateTarget() + displayed = target } private func reset() { diff --git a/Sources/LiveDesktop.swift b/Sources/LiveDesktop.swift index 3466320..054bd2d 100644 --- a/Sources/LiveDesktop.swift +++ b/Sources/LiveDesktop.swift @@ -35,6 +35,7 @@ final class LiveDesktop: NSObject, ObservableObject { @Published private(set) var sensorAvailable = false @Published private(set) var openAngle: Double @Published private(set) var effectStrength: Double + @Published private(set) var followOpenAngle: Bool @Published private(set) var sideFill: SideFill @Published private(set) var error: String? @Published private(set) var needsPermission = false @@ -67,15 +68,18 @@ final class LiveDesktop: NSObject, ObservableObject { let savedStrength = UserDefaults.standard.object(forKey: "effectStrength") as? Double ?? 1 let effectStrength = savedStrength.isFinite && (0.25...1).contains(savedStrength) ? savedStrength : 1 + let followOpenAngle = UserDefaults.standard.object(forKey: "followOpenAngle") as? Bool ?? true self.openAngle = openAngle self.effectStrength = effectStrength + self.followOpenAngle = followOpenAngle sideFill = UserDefaults.standard.string(forKey: "sideFill").flatMap(SideFill.init) ?? .blur - motion = LidMotion(openAngle: openAngle) + motion = LidMotion(openAngle: openAngle, followOpenAngle: followOpenAngle) super.init() let motion = motion sensor.onAngle = { [weak self] angle in let update = motion.receive(angle) - guard update.availabilityChanged || update.beganClosing else { return } + guard update.availabilityChanged || update.beganClosing || update.adoptedAngle != nil + else { return } Task { @MainActor [weak self] in guard let self else { return } if update.availabilityChanged { @@ -89,6 +93,7 @@ final class LiveDesktop: NSObject, ObservableObject { self.error = Self.sensorDroppedMessage } } + if let adopted = update.adoptedAngle { self.storeOpenAngle(adopted) } if update.beganClosing { self.beginRendering() } if update.available, self.isEnabled, !self.isActive, !self.isStarting, !self.resumeAfterWake @@ -152,13 +157,24 @@ final class LiveDesktop: NSObject, ObservableObject { error = "Open the lid to your comfortable viewing position first." return } - openAngle = angle - UserDefaults.standard.set(angle, forKey: "openAngle") + storeOpenAngle(angle) error = nil displayLink?.isPaused = true metalView?.draw() } + private func storeOpenAngle(_ angle: Double) { + openAngle = angle + UserDefaults.standard.set(angle, forKey: "openAngle") + } + + func setFollowOpenAngle(_ value: Bool) { + guard value != followOpenAngle else { return } + followOpenAngle = value + UserDefaults.standard.set(value, forKey: "followOpenAngle") + motion.setFollowOpenAngle(value) + } + func setEffectStrength(_ value: Double) { let strength = value.isFinite ? min(max(value, 0.25), 1) : 1 guard strength != effectStrength else { return } diff --git a/Sources/MainView.swift b/Sources/MainView.swift index 56e1561..7234445 100644 --- a/Sources/MainView.swift +++ b/Sources/MainView.swift @@ -120,9 +120,24 @@ struct MainView: View { ) { Button("Set") { desktop.setOpenPosition() } .controlSize(.small) - .disabled(!desktop.sensorAvailable || desktop.isStarting) + .disabled(!desktop.sensorAvailable || desktop.isStarting || desktop.followOpenAngle) .help("Save the lid angle you are viewing at right now") } + SettingsDivider() + SettingsRow( + "arrow.triangle.2.circlepath", tint: .teal, title: "Follow my open angle", + subtitle: "Take whatever angle you settle at" + ) { + Toggle( + "Follow my open angle", + isOn: Binding( + get: { desktop.followOpenAngle }, set: { desktop.setFollowOpenAngle($0) }) + ) + .toggleStyle(.switch) + .controlSize(.small) + .labelsHidden() + .help("Take whatever angle you settle at as the new open position.") + } } }