Skip to content

Introduce ViewConstraint and layoutWithConstraint - #45

Merged
jdolan merged 12 commits into
mainfrom
layout-measure-arrange
Sep 2, 2026
Merged

Introduce ViewConstraint and layoutWithConstraint#45
jdolan merged 12 commits into
mainfrom
layout-measure-arrange

Conversation

@jdolan

@jdolan jdolan commented Sep 1, 2026

Copy link
Copy Markdown
Owner

ObjectivelyMVC's layout engine has had some fundamental issues for years. Namely, there are two ViewAutoresizing bits that pull in opposite directions: Contain and Fill. Scenarios where a child specified Fill and a parent specified Contain are ambiguous.

There was also a staleness bug in StackView that did not re-compute remaining size while laying out subviews. These sorts of bugs led to layoutSubviews not being idempotent.

This branch aims to fix these problems by introducing the concept of constraints, where a parent negotiates each child's size before proceeding to layout. It allows bottom-up size requests to propagate upward before top-down layout clobbers them.

jdolan added 3 commits August 31, 2026 20:46
StackView::layoutSubviews sized and positioned siblings from each
subview's leftover frame.w/h from the previous layout pass, so a
Contain-sized child's real intrinsic size was only known after it had
already been used (stale) to size and position its siblings within
the same pass -- no fixed point was guaranteed, and a Fill child
inside a Contain ancestor was flatly unresolvable, since the ancestor
can't hand a real bound to a child it's still sizing itself from.

Split layout into a bottom-up View::sizeThatSatisfies pass (what size
do you want, given this ViewConstraint) that runs to completion for a
dirty subtree before any frame is committed, followed by the existing
top-down View::layoutSubviews arrange pass. A Fill child measured
while its Contain ancestor is itself unresolved receives
ViewConstraintUnspecified and degrades to its own intrinsic size,
rather than reading a stale number; it only actually fills once an
ancestor hands it ViewConstraintEqual, during arrange.

View::sizeToSatisfy joins sizeToContain/sizeToFit/sizeToFill as the
resize-and-mark-dirty sibling of sizeThatSatisfies. layoutIfNeeded,
base layoutSubviews's arrange loop, StackView's arrange loop, and
Panel's contentView bypass each used to hand-duplicate a "resize (or
sizeToSatisfy), clearWarnings, layoutSubviews, needsLayout = false"
sequence, with a comment at each explaining why layoutIfNeeded itself
couldn't be called instead (it would re-derive its own, weaker guess
at the applicable constraint instead of using the one the caller
already resolved). That shared tail is now View::layoutWithConstraint
("what size do you want, given this ViewConstraint" -- resolved via
sizeToSatisfy, honoring ViewAutoresizingWidth/Height per axis) and
View::layoutWithSize ("this is your size" -- applied verbatim, no
negotiation, for StackView's and CollectionView's own distribution
math, which overrides a subview's size regardless of its own
autoresizing bits). needsLayout = false is now written in exactly one
place in the codebase instead of four, and layoutWithConstraint always
resolves regardless of isContainer -- gating it on isContainer (an
earlier version of this same change) skipped resizing entirely for a
Fill-only, non-container View, such as Slider's `bar`.

The `constrainedSize` field this went through along the way -- cached
by sizeThatSatisfies, consumed by StackView -- is gone; sizeThatSatisfies
is a pure function of a View's own state, and StackView holds its
subviews' measured sizes in a plain local array scoped to its own
layoutSubviews call, not on the View instances themselves.

Fixes found only by running Examples/Hello against real widgets, not
caught by the synthetic fixture tests: sizeThatSatisfies must skip a
Contain view whose own sizeThatFits override (Text, TableView, Select)
is meaningful only when that view opted into Contain/Fit itself
(TableView.c), a StackView subview without a matching autoresizing bit
must not have its distribution-computed size re-derived through a
constraint it never agreed to honor, and a View re-laid-out standalone
(e.g. a selected TableRowView, whose style rebind marks only itself
dirty) must trust its own established frame as exact rather than
merely an upper bound, or it shrinks back to its unconstrained content
size. Slider and TextView needed an explicit `min-width` in CSS for
the same reason a Contain view's authored size can't otherwise survive
being summed from children that have none of their own.

Adds Tests/ObjectivelyMVC/View.c (StackView and plain-View fixture
graphs asserting exact post-layout frame values) as a fourth
check_PROGRAMS entry alongside Selector/Style/Stylesheet -- there was
previously no ObjectivelyMVC-View test target at all.
Mirrors ObjectivelyMVC-Style: a command-line-tool target building
Tests/ObjectivelyMVC/View.c against the same frameworks, plus a shared
scheme, so the new View check_PROGRAMS test can run from Xcode like
Selector/Style/Stylesheet already could.
Copilot AI lite review requested due to automatic review settings September 1, 2026 01:11

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The new constraint pipeline introduces at least two confirmed behavior bugs (standalone relayout can force an axis to 0, and Panel’s contentView height constraint is ignored), which can cause incorrect layout at runtime.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Introduces a constraint-based sizing pass (ViewConstraint, sizeThatSatisfies, layoutWithConstraint) to make layout idempotent and to resolve historical ambiguity between Contain and Fill, while also fixing StackView measurement staleness by measuring subviews fresh per layout pass.

Changes:

  • Add ViewConstraint + new sizing/layout APIs (sizeThatSatisfies, sizeToSatisfy, layoutWithConstraint, layoutWithSize) and wire them into the default View layout pipeline.
  • Update key containers (e.g., StackView, Panel, CollectionView) to use constraint/size-driven layout entrypoints instead of resize + layoutIfNeeded.
  • Add a new View test suite covering the previously ambiguous and stale measurement scenarios; update CSS to add explicit min-width floors for widgets relying on contain/fill behavior.
File summaries
File Description
Tests/ObjectivelyMVC/View.c Adds layout regression tests for contain/fill ambiguity and StackView staleness/idempotency.
Tests/ObjectivelyMVC/Makefile.am Registers the new View test binary in autotools test runner.
Tests/ObjectivelyMVC/.gitignore Ignores the new View test executable.
Sources/ObjectivelyMVC/View.h Defines ViewConstraint and documents/declares new sizing/layout APIs.
Sources/ObjectivelyMVC/View.c Implements constraint-based sizing/layout and updates default layout behavior to use it.
Sources/ObjectivelyMVC/TableView.c Adjusts sizeThatFits to avoid returning expensive natural size when not acting as a container.
Sources/ObjectivelyMVC/StackView.c Fixes stale measurement by measuring subviews fresh and laying them out via layoutWithSize.
Sources/ObjectivelyMVC/Panel.c Switches content view layout from direct resize to constraint-based layout.
Sources/ObjectivelyMVC/CollectionView.c Uses layoutWithSize for dictated item sizes during layout.
ObjectivelyMVC.xcodeproj/xcshareddata/xcschemes/ObjectivelyMVC-View.xcscheme Adds an Xcode scheme for the new View-focused test/target.
ObjectivelyMVC.xcodeproj/project.pbxproj Adds the new ObjectivelyMVC-View target and related build settings/dependencies.
Assets/stylesheet.css.h Updates embedded stylesheet bytes/length for CSS changes.
Assets/stylesheet.css Adds min-width rules (e.g., Slider/TextView) to ensure contain sizing has a non-zero floor.
Review details
  • Files reviewed: 13/13 changed files
  • Comments generated: 4
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread Sources/ObjectivelyMVC/Panel.c Outdated
Comment thread Sources/ObjectivelyMVC/View.c Outdated
Comment thread Sources/ObjectivelyMVC/View.h Outdated
Comment thread Sources/ObjectivelyMVC/View.h Outdated
jdolan added 8 commits August 31, 2026 21:17
- Panel::layoutSubviews now dictates contentView's size via
  layoutWithSize instead of layoutWithConstraint, so a height change
  (e.g. from an accessory view appearing) isn't silently dropped when
  contentView only opts into ViewAutoresizingWidth.
- View::layoutIfNeeded resolves its per-axis fallback constraint
  independently, so a frame with only one dimension set no longer
  forces the other axis to Equal(0).
- Fix two doc comments in View.h left stale by the layoutWithConstraint/
  layoutWithSize split: layoutSubviews's remark now names the actual
  ViewConstraint enumerators, and layoutWithConstraint's remark no
  longer claims sizeToSatisfy only runs for containers.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Confirmed layout regressions in StackView positioning/alignment relative to padded bounds and build-setting overrides in the Xcode project need to be fixed before merge.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (3)

Sources/ObjectivelyMVC/StackView.c:182

  • When aligning subviews on the secondary axis, StackView uses coordinates relative to 0 instead of bounds.x / bounds.y, so horizontal/vertical alignment ignores padding. Also, pos is advanced using the requested size (subviewSize.*), which can diverge from the actual clamped size in subview->frame.*, leading to gaps/overlaps.
        case StackViewAxisVertical:
          switch (subview->alignment & ViewAlignmentMaskHorizontal) {
            case ViewAlignmentLeft:
              subview->frame.x = 0;
              break;

Sources/ObjectivelyMVC/StackView.c:196

  • When aligning subviews on the secondary axis, StackView uses coordinates relative to 0 instead of bounds.y, so vertical alignment ignores padding. Also, pos is advanced using the requested size (subviewSize.w) instead of the actual clamped subview->frame.w, which can misplace subsequent subviews.
        case StackViewAxisHorizontal:
          switch (subview->alignment & ViewAlignmentMaskVertical) {
            case ViewAlignmentMaskTop:
              subview->frame.y = 0;
              break;

ObjectivelyMVC.xcodeproj/project.pbxproj:1582

  • These build settings override any inherited linker flags/search paths, which can break builds when the project/xcconfig expects $(inherited) to be preserved. Mirror the other targets by using the array form and including $(inherited).
				LIBRARY_SEARCH_PATHS = "$(HOMEBREW_PREFIX)/lib";
				OTHER_LDFLAGS = "-lcheck";
				PRODUCT_NAME = "$(TARGET_NAME)";
  • Files reviewed: 15/15 changed files
  • Comments generated: 2
  • Review effort level: Lite

Comment thread ObjectivelyMVC.xcodeproj/project.pbxproj Outdated
@@ -124,7 +126,7 @@ static void layoutSubviews(View *self) {
break;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Subview frames in ObjectivelyMVC are relative to the superview's padded content origin, not its unpadded frame: View::renderFrame adds superview->padding.left/top when resolving absolute coordinates (View.c, renderFrame), so a subview at frame (0, 0) already renders inside the padding. Offsetting by bounds.x/y here would apply padding twice. The default View::layoutSubviews follows the same convention (subview->frame.x = 0 for left alignment). Leaving the code as is.

Use the array form of LIBRARY_SEARCH_PATHS and OTHER_LDFLAGS with
$(inherited) so project/xcconfig-level linker flags and search paths
are not silently discarded. Applied to all eight test target
configurations sharing the pattern, not only the two flagged.

Addresses CoPilot review feedback on #45.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@jdolan
jdolan merged commit 8cf248e into main Sep 2, 2026
3 checks passed
@jdolan
jdolan deleted the layout-measure-arrange branch September 2, 2026 01:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants