Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions MOTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand Down
2 changes: 1 addition & 1 deletion Sources/HingeApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
45 changes: 43 additions & 2 deletions Sources/LidMotion.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
Expand All @@ -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
Expand All @@ -70,6 +110,7 @@ final class LidMotion {
enabled = value
reset()
updateTarget()
displayed = target
}

private func reset() {
Expand Down
24 changes: 20 additions & 4 deletions Sources/LiveDesktop.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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 }
Expand Down
17 changes: 16 additions & 1 deletion Sources/MainView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
}
}
}

Expand Down