Skip to content

⚠️ REGRESSION PREVENTION: Popover sizing — the two-symptom trap and the only correct solution #51

@eonist

Description

@eonist

Purpose

This issue exists because the same two regressions have been introduced and "fixed" repeatedly in a cycle. Every fix for one symptom broke the other. This issue documents the full history, the root cause, and the exact constraints that must never be violated.

Before touching AppDelegate.swift, PopoverView.swift, or any frame/sizingOptions/contentSize code — read this entire issue first.


The Two Symptoms

Symptom A — Left Jump

The popover flies to the far left of the screen when opened, instead of appearing below the status bar icon.

Cause: NSPopover.contentSize.width changed between opens. NSPopover re-calculates its anchor position from scratch every time contentSize changes — including width. Even a 1pt width difference causes a full re-anchor and the popover lands in the wrong position.

Symptom B — Empty Black Space

The popover shows a large empty black area below the content. Content only fills ~300pt but the popover is 480pt tall.

Cause: contentSize.height was hardcoded to 480pt regardless of actual content height.


The Regression Cycle (commit history)

Attempt What was done Symptom A Symptom B
Early contentSize = (340, 480) fixed forever ✅ No jump ❌ Empty space
Fix B sizingOptions = .preferredContentSize + .frame(width: 340) ❌ Jump returns ✅ Height fits
Fix A Back to contentSize = (340, 480) + ScrollView ✅ No jump ❌ Empty space
Fix B sizingOptions + KVO to update contentSize ❌ Jump returns ✅ Height fits
Fix A Remove KVO, fixed contentSize again ✅ No jump ❌ Empty space
v1.3 sizingOptions + .frame(idealWidth: 340) ✅ No jump ✅ Height fits

The cycle always happened because: fixing Symptom B required dynamic height (which changed contentSize) and fixing Symptom A required stable contentSize.width. These appear contradictory — but they are NOT, if you use idealWidth correctly.


Why Each "Fix" Failed

popover.contentSize = NSSize(width: 340, height: 480)

  • Fixes: left jump (width never changes)
  • Breaks: height fitting (480pt always, empty space when content is shorter)

sizingOptions = .preferredContentSize + .frame(width: 340)

  • .frame(width: 340) is a layout constraint — it constrains the view's layout width but does NOT guarantee that preferredContentSize.width is reported as exactly 340 on every nav state
  • When navigating to JobStepsView (which has .frame(width:340, height:480)), SwiftUI may report a different ideal width for the root Group
  • Result: preferredContentSize.width changes => contentSize.width changes => left jump

❌ KVO on preferredContentSize to manually update contentSize

  • Any write to contentSize — even height-only — triggers NSPopover to fully re-anchor
  • There is no AppKit API to update height without also re-anchoring horizontally
  • Result: left jump on every height change

❌ Wrapping jobListView in ScrollView for dynamic height

  • ScrollView reports infinite preferred height to SwiftUI's layout system
  • When combined with fixedSize(vertical: true), this makes the popover grow to fill the screen
  • Without fixedSize, height defaults to whatever the parent offers (480pt) => empty space

The Only Correct Solution

These two lines must always exist together and never be changed:

AppDelegate.swift

hc.sizingOptions = .preferredContentSize
// DO NOT set popover.contentSize anywhere — not here, not anywhere else

PopoverView.swift — root Group modifier

.frame(idealWidth: 340)
// THIS IS THE KEY. idealWidth, NOT width, NOT width+height.

PopoverView.swift — jobListView usage in body

jobListView
    .fixedSize(horizontal: false, vertical: true) // measure natural height
    .frame(maxHeight: 480, alignment: .top)        // cap at 480, pin to top
// DO NOT wrap jobListView in ScrollView

JobStepsView.swift — fixed drill-down frame

.frame(width: 340, height: 480)
// Width MUST be 340 to match idealWidth. Height fixed is fine (ScrollView handles it).

Why idealWidth Solves Both

idealWidth tells SwiftUI: "my preferred width for layout negotiation is 340pt".

NSHostingController with sizingOptions = .preferredContentSize reads the SwiftUI view's ideal size (not its layout size) and publishes it as preferredContentSize.

Because idealWidth = 340 is set on the root Group — which wraps all nav states — preferredContentSize.width is always exactly 340, regardless of which nav state is active. Height varies freely with content.

Result:

  • contentSize.width is always 340 → NSPopover anchor never moves → no left jump
  • contentSize.height varies with content → popover fits content → no empty space

Complete List of Things That Will Cause Regression

❌ Setting popover.contentSize anywhere (manually)
   → Overrides preferredContentSize → breaks dynamic height OR causes re-anchor

❌ Changing hc.sizingOptions to anything other than .preferredContentSize
   → NSPopover stops tracking SwiftUI height → fixed size → empty space

❌ Removing sizingOptions line entirely
   → Same as above

❌ Changing .frame(idealWidth: 340) to .frame(width: 340)
   → Layout constraint ≠ ideal size → preferredContentSize.width may not be 340
   → Width changes on nav state change → left jump

❌ Changing .frame(idealWidth: 340) to .frame(width: 340, height: 480)
   → Fixed height → preferredContentSize.height = 480 always → empty space

❌ Removing .frame(idealWidth: 340) entirely
   → SwiftUI reports unconstrained ideal width → unpredictable → left jump

❌ Adding KVO / observers on preferredContentSize to set contentSize
   → Any contentSize write triggers full NSPopover re-anchor → left jump

❌ Wrapping jobListView in ScrollView
   → ScrollView preferred height = infinite → fixedSize makes popover enormous
   → Without fixedSize → height = 480 always → empty space

❌ Changing JobStepsView .frame width away from 340
   → When navigating to JobStepsView, preferredContentSize.width changes
   → NSPopover sees width change → re-anchors → left jump

Checklist Before Merging Any Sizing Change

  • Does AppDelegate still have hc.sizingOptions = .preferredContentSize?
  • Is popover.contentSize set anywhere? (It must NOT be)
  • Does the root Group in PopoverView still have .frame(idealWidth: 340) and nothing else?
  • Is jobListView free of ScrollView?
  • Does jobListView use .fixedSize(horizontal: false, vertical: true) + .frame(maxHeight: 480, alignment: .top)?
  • Is JobStepsView frame width still exactly 340?
  • Tested: open popover → close → open again → no left jump?
  • Tested: open popover with no jobs → open with active jobs → height fits both times?

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingdocumentationImprovements or additions to documentation

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions