From 171f1a779240b66edef8f6642db82b01979c17ba Mon Sep 17 00:00:00 2001 From: Renan Sousa Date: Fri, 11 Sep 2026 11:51:12 -0300 Subject: [PATCH] Follow the angle the lid settles at Follow my open angle, on by default, moves the baseline to whatever angle the lid settles at, so parking the lid anywhere leaves the desktop clear and only closing from there folds. Adoption needs a reading held within 1.5 degrees for 750 ms while the lid sits at least three degrees above its lowest recent value. That travel requirement is what separates a deliberate repark from a pause partway through a close. A single degree threshold could not do it, because the sensor reports integer degrees and noise between adjacent readings alone would satisfy it, which would drop the fold while the user was holding the lid still. Adoption moves the baseline without resetting the filter, so the fold unwinds through the damped response instead of snapping to zero. Co-Authored-By: Claude Opus 5 --- MOTION.md | 4 ++-- README.md | 2 +- Sources/LidMotion.swift | 45 ++++++++++++++++++++++++++++++++++++-- Sources/LiveDesktop.swift | 24 ++++++++++++++++---- Sources/SettingsView.swift | 22 ++++++++++++++----- 5 files changed, 83 insertions(+), 14 deletions(-) diff --git a/MOTION.md b/MOTION.md index 0593f06..0f9eecb 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 c010a52..450a487 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. 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. 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/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 d828503..f34480c 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 error: String? @Published private(set) var needsPermission = false private let sensor = LidSensor() @@ -59,14 +60,17 @@ 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 - motion = LidMotion(openAngle: openAngle) + self.followOpenAngle = followOpenAngle + 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 { @@ -76,6 +80,7 @@ final class LiveDesktop: NSObject, ObservableObject { self.error = "The lid sensor stopped responding. Turn Hinge on again to reconnect." } } + if let adopted = update.adoptedAngle { self.storeOpenAngle(adopted) } if update.beganClosing { self.beginRendering() } } } @@ -118,13 +123,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/SettingsView.swift b/Sources/SettingsView.swift index 6855fbf..e39fda6 100644 --- a/Sources/SettingsView.swift +++ b/Sources/SettingsView.swift @@ -71,8 +71,16 @@ struct SettingsView: View { } Spacer() Button("Set open position") { desktop.setOpenPosition() } - .disabled(!desktop.sensorAvailable || desktop.isStarting) + .disabled( + !desktop.sensorAvailable || desktop.isStarting || desktop.followOpenAngle) } + Toggle( + "Follow my open angle", + isOn: Binding( + get: { desktop.followOpenAngle }, set: { desktop.setFollowOpenAngle($0) }) + ) + .toggleStyle(.switch) + .help("Take whatever angle you settle at as the new open position.") Divider() Toggle("Launch at login", isOn: launchAtLogin) .toggleStyle(.switch) @@ -107,10 +115,14 @@ struct SettingsView: View { .font(.system(size: 12)) .fixedSize(horizontal: false, vertical: true) } else { - Text("Starts at 100°. Set your comfortable open position once, and Hinge remembers it.") - .font(.system(size: 12)) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) + Text( + desktop.followOpenAngle + ? "Hinge takes the angle you settle at as your open position." + : "Starts at 100°. Set your comfortable open position once, and Hinge remembers it." + ) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) } } .padding(28)