Conversation
Brightness keys: - Clamshell: add a raw keyDown fallback (keycodes 144/145) so the keys work with the lid closed, where macOS suppresses the NX_SYSDEFINED brightness event. Mirrors MonitorControl's dual-path capture. (#21) - Post-upgrade: re-check Accessibility after launch and re-arm the tap once trust settles, so updating no longer leaves the keys dead. Menu bar: - Keep Awake indicator: a small orange dot fades in on the status icon while Keep Awake holds the display awake. - Update Available row now shows the pointing-hand cursor on hover, like the Sponsors links. Contains temporary, privacy-safe diagnostic logging (com.crisp.app "upgrade"/"clamshell" categories) for the brightness-key work, to be stripped once #21 is confirmed on macOS 27, before the 1.3.3 release.
…EDR surface alive
…math Bug A (critical, white-out): the overlay window rendered once per factor change. WindowServer only honors a cross-window compositingFilter while the window keeps presenting; roughly a second after the last present it promotes the window to direct scanout and drops the filter, so the raw near-white EDR layer covers the screen. Hardware testing confirmed the symptom: every re-render made the desktop briefly visible, then it whited out again once WindowServer dropped the filter. Fix, adopted from BrightIntosh (the shipping open-source app this technique comes from): render continuously at 5fps for the life of the window (a per-overlay Timer, 0.2s, .common run loop mode), not just on factor changes. Also adopted: compositingFilter "multiply" (was "multiplyBlendMode"), 1x1 drawable (was 8x8), window level at CGShieldingWindowLevel (was screenSaver+1), the fuller collectionBehavior set, and an anti-flash gate (window starts alpha 0, revealed only after the first frame's command buffer completes, with a 0.1s fallback). Bug A part 2 (factor ceiling math): the flat min(potentialHeadroom, 2.0) ceiling crushed whites; a full-screen panel cannot actually deliver 2x. BrightnessBoostMath now uses BrightIntosh's per-panel formula: built-in ceiling = 1 + bonus * min(currentEDR/referenceEdr, 1.0), with (referenceEdr, bonus) = (2.66, 0.50) on 600-nit-SDR models and (3.2, 0.59) elsewhere, so the ceiling tops out at 1.50 or 1.59 (never 2.0). Slider max follows the same constants. A readiness gate (currentEDR <= 1.05) applies a small pending factor (1.12) instead of the target while EDR hasn't ramped yet, since slightly-above-1.0 content is itself what prompts macOS to ramp it; the existing 500ms headroom poll re-syncs once it does. External displays keep the current-headroom approach, clamped to [1.0, 2.0], with the same readiness gate. BrightnessBoostMath stays a pure enum (isBuiltin/model/currentEDR passed in) so scripts/check-boost-math.swift keeps compiling standalone; its assertions now cover both panel classes, the readiness gate, and the external clamp.
Bug B: the built-in panel is 120Hz, but every brightness glide (BrightnessService.setBrightnessSmooth and BrightnessBoostService.animateMaxBrightness) stepped its Timer at a fixed 0.016s interval, so motion read as ~30fps on a 120Hz panel. Halving the step interval to 0.008s doubles the step rate to ~125Hz. Durations and easing are unchanged.
… on disable Bug C: disabling Extra Brightness faded brightness down to 100 first, then separately collapsed maxBrightness from its raised value back to 100. Because the slider thumb renders as value/maxBrightness, that two-phase sequence made it visibly drop (as brightness fell while max stayed high) and then rise back to 100% (as max fell while brightness sat at 100). Replaced with a single combined collapse: one BrightnessAnimator drives a progress value from 1 to 0, and each tick derives both brightness and maxBrightness from it (100 + p * (v0 - 100), same for max0), so the thumb position rises monotonically to 100% instead of dipping. The overlay factor is computed each tick from the live brightness but the frozen starting maxBrightness (max0), so the multiplier tracks the thumb instead of jumping when maxBrightness itself starts moving. The animation reuses the same maxAnimators slot the enable-side grow animation uses, so cancelAnimation on a rapid re-enable stops whichever of the two is running; a new isCollapsing set (collapsingDisplays) is cleared at that same point so syncOverlay (headroom poll, other callers) doesn't skip the display forever. syncOverlay itself now returns early while a display is collapsing, so those callers cannot fight the collapse mid-flight.
…lapse, 120Hz catch-up glide The collapse formula raised an already-below-100 brightness up to 100 when Extra Brightness was toggled off; native-range values now stay where the user left them, only the boosted excess collapses. Also steps the ambient refresh catch-up glide at 0.008s like the other animators.
…locates mid-collapse If a monitor disconnects during the 0.35s disable collapse, the weak display reference goes nil and the animation closure used to bail before removing the displayID from collapsingDisplays, leaving syncOverlay muted forever for any display that later reuses that CGDirectDisplayID.
Stepped multiply factors with beeps on the built-in panel. Its run produced the round-2 diagnosis: currentEDR pins at the constant peak small-area ratio (3.998 here) and is useless for clamping a full-screen boost, and every render made the screen briefly visible before whiting out again, which identified WindowServer dropping the idle multiply filter on scanout promotion as the root cause.
… boost ceiling Hardware calibration with the fixed continuous-render overlay showed the panel renders every factor up to its reported headroom (about 4x) clean, with no clipping and no headroom sag. The conservative 1.5x per-model cap had been calibrated against the white-out artifact, which is why the boost looked far dimmer than BetterDisplay (measured: it scales gamma tables to an endpoint of 1.82, roughly 3.7x linear, near the full headroom). The boost region now maps exponentially onto the live headroom (factor = currentEDR^t), exposure-stop style, so equal slider steps give equal perceived brightness ratios. Slider ceiling returns to 100 * min(potential, 2) = 200 percent, and the BrightIntosh per-model constant table plus the sysctl model lookup are gone as dead code. Calibration spikes committed as scripts/edr-spike3.swift and scripts/edr-inspect.swift.
Add HDRToggleView, a visible per-display HDR on/off row for HDR-capable externals (built-in never shows it, matching System Settings), placed above Extra Brightness in the detail view since boost enable can still switch HDR on for it. BrightnessBoostService gains isEligibleForHDRToggle, isHDREnabled, and setHDRPreference. Turning HDR off while boost is enabled for that display first runs boost's own disable-collapse to completion (polling collapsingDisplays, then a short settle) before switching to SDR, so the collapse animation is not left fighting an SDR display underneath it. Remove the silent-revert machinery this replaces: undoHDRSwitchIfNeeded, the crisp.BoostSwitchedHDR.<uuid> flag, and the SDR-restore calls in the boost disable path and in prepareForTermination. Boost enable still auto-switches HDR on for SDR externals; it just no longer reverts that on disable or quit, since HDR is now a toggle the user owns directly.
README: list per-display HDR on/off under the Color bullet. Spec: append a revision note explaining boost no longer silently reverts HDR on disable or quit now that HDR is its own toggle.
…equests An HDR-off request waits out the boost collapse before switching modes; a quick off-then-on flip left the stale off request firing setHDRMode after the user re-enabled, and the wait was gated on the isEnabled flag, which a collapse started from the Extra Brightness row has already cleared, letting the mode switch land mid-collapse. Gate the wait on the live collapsingDisplays set and drop any request that is no longer the newest for its display.
Color temp and other gamma adjustments died on wake until relaunch. The wake path applied them once, ~2.5s after didWake, but WindowServer keeps settling for several seconds (ICC restore, link retraining) and clobbers the transfer table after that single shot. Relaunching worked because it applied once at a settled moment, which is exactly what the reporter observed. Two-part fix: the wake restore now runs three passes (at ~2.5s, ~6.5s, ~14.5s) so the last lands after the churn, and refreshDisplays now reapplies the active in-memory adjustment for kept displays on every reconfiguration (previously only newly added displays got this), so any mid-session table reset heals on the next callback. Every pass is an idempotent no-op when state is already correct.
Event-driven complement to the timed wake passes: ColorSync posts distributed notifications (com.apple.ColorSync.DeviceProfilesNotification / DisplayProfileNotification) when display profiles change, which is the prime suspect for the post-wake transfer-table clobber. Reapply active adjustments in direct response. No feedback loop: reapply only writes transfer tables, never profiles, and resetSingleDisplay drops its display from activeAdjustments before its ColorSync write. If sleep-testing shows this catches every clobber, the timed passes can be deleted.
Parallel sessions in separate worktrees clobbered each other's builds by both swapping into /Applications/Crisp.app. Each worktree can keep its own bundle instead: cp -R /Applications/Crisp.app ./Crisp-dev.app once, then make dev CRISP_APP=$PWD/Crisp-dev.app. Running stays exclusive (dev.sh pkills any live instance; one menu bar app, one set of hardware writers), but builds no longer overwrite each other.
Per-tick cost (window setFrame ~3-4ms plus layout) pushed tall-panel animations to sustained 60Hz, where frame-paced spring time ran at half speed and snapped back, reading as a jump. Spring time now advances by wall clock clamped to a 21ms catch-up window; the glass backdrop is an oversized fixed canvas the window clips instead of resizing; container views skip autoresize passes and frame-change notifications; frames commit via CA (display: false) with unchanged frames untouched. Adds per-tick gap/cost/sub-timing forensics under com.crisp.app/panelcanvas.
Gate the two macOS 15-only API sites (defaultScrollAnchor role variants, Color.mix boost tint) with macOS 14 fallbacks and drop the deployment target to 14.0 in project.yml and release.sh. CI's release dry run now compiles at target 14.0.
Rework the panel so the NSPanel window never resizes while an animation is in flight (a shadowed transparent window costs the WindowServer 5-9ms per per-frame resize, the root cause of the animated-resize cadence failures). - The window is larger than the visible panel; its transparent margins host the shadow, and window frames change only at rest (setWindowHeight). - A layer-driven shell carries the visible glass edge; a twin view carries a CA clone shadow (outset one device pixel, knockout-masked interior) that tracks the shell in a single Core Animation transaction. - FrameSpring gains a display-link retarget so the spring re-syncs when the panel lands on a different-refresh screen; miss-only tick logging. - PanelRootView routes margin clicks as outside-clicks (native-menu dismiss); outside-click hit tests use the visible shell rect, not the window frame. - Shadow tints are picked from NSApp.effectiveAppearance (rim/blur alphas per light/dark), applied on flight and settle.
Move the tap gesture after the row padding so the hit shape matches the hover highlight; clicks on the highlighted edge were previously dead.
Four coupled fixes so a nested reveal (Image Adjustment, preset/resolution lists) animates smoothly instead of the inner content drifting or the outer clip snapping: - Top-static: give each block's hosting canvas slack above its content and top-glue the content (BlockHost), so a mid-reveal curtain animates inside a fixed top-aligned frame instead of NSHostingView centering it and dropping the top on open. - Close-clip: clamp the spring's per-block height to the taller of the flight's endpoints, not the live contentHeight (which is already the new short value on a close), so the outer clip springs shut instead of snapping. - Spring phase: start the FrameSpring clock at flight start, not the last idle vsync, so the first active tick does not jump a stale frame ahead. - Shell sync: buffer each spring tick one frame so the layer-driven shell lands on the same frame SwiftUI presents the curtain (the residual "inner lags outer").
The appearance-tied rim/shadow tints were only recomputed on a resize flight, so the first on-screen open (before the launch-time appearance had resolved) and any light<->dark switch left the panel wearing the other mode's rim until an expansion refreshed it. Add refreshAppearance() and call it on every open and from a system-theme-change observer.
Brings the static-window split-canvas resize engine, clone shadow, and the nested-reveal animation/appearance fixes onto 1.3.3-dev. Conflict: the split-canvas rework removed the monolithic `struct MenuBarView` (panel content is now assembled as blocks in AppDelegate), while 1.3.3-dev had added the speaker-volume feature (issue #23) inside it. Resolved by taking the split-canvas structure. The volume support layer, the "Show Volume Sliders" settings toggle, and ReconnectDisplaysSection merge in cleanly; the per-display volume slider UI and the panel-open DDC re-probe are re-hosted into the split-canvas panel in the follow-up commit.
Re-host 1.3.3-dev's speaker-volume UI (issue #23) into the block-based panel after the merge removed the monolithic MenuBarView that carried it: - DisplayHeaderBlock renders VolumeSliderView under the brightness slider when showVolumeSliders is on and the display answered the DDC volume probe. The block re-renders on the setting/support change and its height flows through BlockHost to the panel spring, so it glides in like the other rows. - showPanel re-probes DDC volume for externals that have not reported support yet, matching the old panel's on-open retry. The "Show Volume Sliders" settings toggle and the volume support layer came in with the merge; this restores the actual sliders.
…he EDR overlay On third-party HDR monitors the OS-reported live EDR headroom is not trustworthy (observed pinned at 1.2 on an AOC Q27G3XMN while a 2.87x gamma table delivered real brightness), and the overlay pipeline hard-clamps at that reported value without CAEDRMetadata: near-white detail crushed for almost no brightness gain. Externals now scale the display transfer table above 1.0 instead (the method BetterDisplay uses on these displays; tops above 1.0 are honored while the monitor is in HDR mode), mapped exponentially to a fixed two-stop luminance ceiling converted through the panel gamma (4.0^(1/2.2), a 1.88 table top). The first attempt used a 2.5 encoded top, which is a 7.5x mid-tone luminance blast through the panel's 2.2 gamma and washed out on hardware, so the ceiling is defined in linear luminance terms. The built-in XDR path keeps the EDR overlay, where reported headroom is honest and live. While boosted, the boost sync is the single transfer-table writer (brightness writes above 100 skip the pinned-at-100 dim write) and GammaService's per-entry clamp lifts to the boost factor so image adjustments compose with boost. Known tradeoff inherited from the gamma method: true HDR content may clip while boosted.
The auto-reenable shipped in 58b5cf5 never fired in the state it was built for. Live repro (SLS-disabling the external to simulate the cable pull, with the internal disabled via Crisp and its record held) exposed two independent failures on macOS 26: 1. When the last real display goes away, macOS spawns a placeholder display (vendor 'unkn' 0x756E6B6E, model 'virt' 0x76697274, no NSScreen). physicalActiveDisplayCount() only excluded Crisp's own virtual displays, so the placeholder counted as an active physical screen and the zero-active guard never passed. It is now filtered by its fingerprint. 2. In that state SLSGetDisplayList shrinks to just the placeholder, so resolveCurrentID returned nil for every record and the compactMap dropped all restore candidates. Records now fall back to their last-known displayID: SLSConfigureDisplayEnabled still honors a stale ID for attached hardware (verified live, twice), and detached hardware fails at CGCompleteDisplayConfiguration (error 1001) so the loop safely moves on. Verified on hardware: with the fix, the internal panel comes back ~2s after the last active display disappears (the settle delay), where the unfixed build sat black indefinitely.
Issue #22 flagged that the 14.0 compile floor is not the runtime floor: the binary links private symbols with -undefined dynamic_lookup, so a symbol missing on an older macOS crashes at first call, not at launch. Adds scripts/runtime-floor-probe.swift, a standalone check of every private surface Crisp touches (DisplayServices, CoreDisplay, SkyLight, CGS externs, IOAVService, CoreBrightness, MonitorPanel classes and selectors including preset objects, CGVirtualDisplay, gamma tables). Verified in tart VMs: macOS 14.8.7 and 15.7.7 both pass every check, and the release bundle launches and stays up with no crash reports. Not verifiable in a VM: real DDC I2C traffic and EDR/HDR behavior on physical external monitors (no IOAVService devices, virtual display only). README and site requirements updated from 15 to 14.
…DME and site The comparison pages still claimed Crisp cannot do XDR/HDR brightness boost, and the landing pages did not mention extra brightness, the HDR toggle, or DDC speaker volume at all. Flip the comparison table cell, add a DDC volume row, add feature cards on both landing pages, and sweep the remaining macOS 15 references (README badge, comparison footers, both guides) down to macOS 14.
Fix the 释放/亮过 collocations on the extra brightness card, replace the stiff 逐屏 modifier with 每块屏独立开关 HDR, and keep terminology consistent per page: the landing page says 额外亮度 (matching the app UI string), the comparison page sticks to its established 亮度增强.
内置显示器 becomes 内建显示器 to match Apple's mainland macOS term, the blocked-disconnect alert drops the stiff 已拒绝 for 无法, and the preset color VoiceOver label loses its redundant 颜色 suffix (color names like 蓝色 already end in 色). Kept 更大文本: Apple's own Displays pane pairs 更大文本 with 更多空间, which is this context.
The clamshell brightness-key fix is confirmed on 1.3.3-beta.1 (macOS 27 beta, FrancoLan's test matrix on the issue), so the upgrade/clamshell DIAG loggers come out as the WIP commit promised. The F14/F15 observe-only case goes with them: it existed solely to detect testers whose OS routes brightness there, and never consumed the events.
…c blocks' SwiftUI layout in flight Every per-tick clip resize dragged all ~14 block hosting views through the window constraint engine's layout walk, each running a SwiftUI view-graph render: ~10 renders per 8.3ms frame budget, measured as the panel's sustained 60Hz stretches on the 120Hz built-in (250-290 host layouts per ~27-tick flight). During a flight, blocks whose height is not animating have unchanged geometry and content, so their layout() is skipped wholesale via a muteLayout flag on the hosting-view subclass. The animating block stays live: dropdown-style nested curtains report their final height once and then animate at the SwiftUI presentation layer, which needs per-frame layout (muting it froze the inner reveal until settle). Settle and snap unmute everything and force one real pass at rest, so hover states and hit-zone mappings resync. Measured: host layouts per flight 250-290 down to ~50, dropped frames from nearly every other frame to zero on short and medium panels. Tall dropdown flights still drop a few frames to the animating block's own curtain render; making that cheaper means clip-driven nested reveals, deferred. Ruled out on the way (bare-link probe + samples): ProMotion frame-rate voting, display-link API choice, needsLayout clearing, and postsFrameChangedNotifications muting (the constraint engine drives the walk regardless). The flight log line (ticks, hostLayouts, blocks) stays: it is the cheap regression canary for exactly this failure mode.
Resolution, refresh rate, preset/color profile, and image adjustment reveals were SwiftUI curtains inside the single ddetail block, so every frame of a dropdown flight re-rendered the whole detail view graph and tall flights dropped frames. Each dropdown (and the rows around it) is now its own PanelBlock: content renders once at natural height, the canvas animates the clip, and the open flags live in PanelSectionState as per-display ID sets shared by the chevrons and the canvas. DisplayModeSection's switching logic moved verbatim into DisplayModeController; preset/profile names into DisplayProfileController. ColorProfileView now loads at panel build (async) so its reveal is a curtain instead of a pop-in. Measured: dropdown flights now hold the display's full rate (120Hz built-in, 165Hz external) with per-tick cost ~0ms and 1-2 isolated misses across hundreds of ticks, versus misses on every tall flight.
…imezone Search Console flagged the bare date on both landing pages: invalid datetime value and missing timezone for uploadDate.
…light Blocks opening from zero height now fade their host in with the spring scalar (and fade out on collapse), giving every expandable section the same soft reveal the Brightness Keys menu had. The detail band is painted on the clip layer (banded blocks, tinted via tintBands) so the fade dims content but never the band. New PanelBlock.liveInFlight opts head blocks out of layout muting so chevron rotations animate during flights.
A click-glide steps brightness at ~125Hz and every step re-renders the display's observers. Three costs made that visibly laggy on external displays: HDRToggleView asked WindowServer whether the display supports HDR on every render (SLSDisplaySupportsHDRMode IPC, now cached per display and invalidated on reconfiguration), the mode blocks recomputed resolution groups on every render (now cached behind a narrow relay on currentDisplayMode/availableModes), and the software-gamma fade path wrote transfer tables on the main thread (now dispatched to the background queue like the other paths).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Merges the 1.3.3-dev line for the 1.4.0 release.
Highlights: Extra Brightness for XDR/HDR displays, monitor speaker volume over DDC, macOS 14 (Sonoma) support, clamshell brightness keys, panel performance rework, and a broad round of fixes. Full notes ship with the release.
Includes contributions from @shaw-baobao (#24), @FrancoLan (#21 testing), and @YuriNachos (#27).