Skip to content

Feature: Implement the Auto Layout constraint solver - #643

Merged
fredkiefer merged 12 commits into
gnustep:masterfrom
DTW-Thalion:fix/540-cassowary-solver
Aug 17, 2026
Merged

fredkiefer merged 12 commits into
gnustep:masterfrom
DTW-Thalion:fix/540-cassowary-solver

Conversation

@DTW-Thalion

@DTW-Thalion DTW-Thalion commented Jul 20, 2026 •

Copy link
Copy Markdown
Contributor

GSCassowarySolver was a stub, so activating layout constraints produced no solution and constrained views laid out to a zero rect. This implements the solver and wires up activation so the anchor and NSLayoutConstraint APIs actually position views.

Changes:

  • GSCassowarySolver: implement the simplex orchestration on top of GSCSTableau. Adding and removing required and non-required constraints, the artificial-variable path, objective optimisation, edit variables (suggestEditVariable:equals:), and reading the external variable values back out as a GSCSSolution.
  • GSCSVariable: -isRestricted classified external variables as restricted and dummy variables as unrestricted, the reverse of the classification the tableau relies on for infeasible-row detection and exit-variable selection.
  • GSCSTableau: -substituteOutTerm:withExpression:inExpression:subject: folded in the terms of the expression being modified instead of the terms of the substituting expression.
  • GSCSEditInfo: retain the objects it stores, expose the error variables and previous constant, and track the constant as a CGFloat so fractional values are preserved.
  • GSAutoLayoutEngine: -alignmentRectForView: and its helpers returned NSZeroRect; return the solved minX, minY, width and height for a view.
  • NSLayoutConstraint: -setActive:, +activateConstraints: and the anchor methods now install a constraint against the layout engine of the view that owns it (the nearest ancestor shared by its two items) and remove it on deactivation. Constraints activated before their view has a window are installed once it moves into one. A constraint created with a factory method or initialiser is inactive until activated, matching AppKit.
  • NSWindow: when the layout engine is created, the content view is pinned to its own bounds, so subview constraints resolve to absolute positions without the application constraining the root view.
  • GSAutoLayoutEngine / GSAutoLayoutVFLParser: the engine multiplied a relational constraint's constant by -1 for the right, top and trailing attributes, which inverted the constant for anchor constraints and for those attributes in a visual format string. The constant is now applied as written, and the visual format parser negates the spacing for vertical arrangements.
  • Resizing a window reflows its constrained subviews: the engine keeps the content view's size constraints and refreshes them from the current bounds, and a content view lays its constrained subtree out again when its frame changes.
  • NSControl reports an intrinsic content size (its cell size), and NSView initialises the content hugging and compression resistance priorities, so a control can be laid out from its content without an explicit size constraint. This also fixed a freed-expression use in GSCSConstraint's inequality factory and a duplicate edit constraint in the solver, both first reached once the intrinsic size constraints run.

The GSCSVariable, GSCSTableau and GSCSEditInfo errors were never reached while the solver was stubbed.

The added gui tests drive subviews through the anchor and visual-format APIs and check the solved frames; they skip when no display is available.

Closes #540.

@DTW-Thalion
DTW-Thalion requested a review from fredkiefer as a code owner July 20, 2026 14:10
@DTW-Thalion DTW-Thalion changed the title Implement the Auto Layout constraint solver Feature: Implement the Auto Layout constraint solver Jul 20, 2026
@DTW-Thalion

Copy link
Copy Markdown
Contributor Author

@fredkiefer - you can let this PR marinade for a while. This is a feature, not a bug fix per se - I noted a lot of the plumbing existed, but wasn't wired up and I don't think even tested, so I wired it up and fixed the problems I could find against MacOS AppKit related behaviors. So this is a welcome diversion from test harnesses, but needs some solid testing by others in actual environments.

@fredkiefer

Copy link
Copy Markdown
Member

I hope to find time to look into this over the weekend. It is too much to work on in the evening.

@DTW-Thalion

Copy link
Copy Markdown
Contributor Author

Sounds good - tackle #692 if you could

GSCassowarySolver was a stub, so activating layout constraints produced no
solution and constrained views laid out to a zero rect. Implement the simplex
orchestration on top of GSCSTableau: adding and removing required and
non-required constraints, the artificial-variable path, objective
optimisation, and reading the external variable values back out as a
GSCSSolution.

The supporting primitives had two errors that were never reached while the
solver was stubbed:

- GSCSVariable -isRestricted treated external variables as restricted and
  dummy variables as unrestricted, the opposite of the classification the
  tableau relies on for infeasible-row detection and exit-variable selection.

- GSCSTableau -substituteOutTerm:withExpression:inExpression:subject: folded in
  the terms of the expression being modified instead of the terms of the
  substituting expression.

GSAutoLayoutEngine -alignmentRectForView: and its supporting methods were also
unimplemented; return the solved minX, minY, width and height for a view.
Implement -suggestEditVariable:equals: so a variable can be pinned to a
succession of values, which the layout engine uses when an intrinsic size or a
view geometry changes. On the first suggestion a non-required edit constraint
is added for the variable; subsequent suggestions adjust its constant and
restore feasibility with a dual optimisation pass.

GSCSEditInfo now retains the objects it stores, exposes its error variables and
previous constant, and tracks the constant as a CGFloat so fractional layout
values are preserved.
Activation was a no-op, so -setActive:, +activateConstraints: and the anchor
convenience methods never installed a constraint and constrained views were
never laid out. Route an activated constraint to the layout engine of the view
that owns it, which is the nearest ancestor shared by its two items, and take
it back out on deactivation. Constraints activated before their view has a
window are installed once the view moves into one.

A constraint created with +constraintWithItem:... or an initialiser is now
inactive until it is activated, matching AppKit.

The added test drives a subview through the anchor API and checks the solved
frame, and skips when no display is available.
When the layout engine is created for a window, add required constraints
fixing the content view's origin and size to its current bounds. Subview
constraints expressed relative to the content view then resolve to absolute
positions in the content view's coordinate system without the application
having to constrain the root view itself.
The layout engine multiplied a relational constraint's constant by -1 for the
right, top and trailing attributes. This inverted the constant for anchor
constraints such as [a.rightAnchor constraintEqualToAnchor: b.rightAnchor
constant: -30], and gave the wrong result for the same attributes in a visual
format string.

Apply the constant as written, and negate the spacing in the visual format
parser for vertical arrangements, where a positive spacing runs toward
decreasing y in a non-flipped view.
The content view was pinned to its bounds once when the layout engine was
created, so resizing a window left the pins stale and constrained subviews kept
their original geometry. The engine now owns the content view's size
constraints and refreshes them from the current bounds, and a content view
lays its constrained subtree out again when its frame changes.
Give NSControl an intrinsic content size, its cell size, so a control can be
laid out from its content without an explicit width or height constraint, and
initialise the content hugging and compression resistance priorities to their
default low and high values, which the engine uses to weight the intrinsic size
constraints.

Two supporting fixes were reached for the first time now that the hugging and
compression constraints are added to a working solver:

- GSCSConstraint +constraintWithLeftVariable:operator:rightVariable: released
  the right hand side expression before passing it to the constraint
  initialiser, which left the inequality constraint holding a freed expression.

- GSCassowarySolver added a second edit constraint in -suggestEditVariable:
  equals: even when one had already been added for the variable, so the two
  disagreed on the value. Edit constraints are now recorded as they are added
  and reused.
START_SET creates an autorelease pool and END_SET releases it, so the pool
these tests create around the set is redundant.
Comment thread Source/GSCassowarySolver.m Outdated
{
// FIXME Remove constraint from solver
FOR_IN(GSCSConstraint *, constraint, constraints)
[self addConstraint: constraint];

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is fine for now, but later we should split up -addConstrain: so that we only solve the resulting constraints once here and not for each of the added ones separately. The same is true for removal.

Comment thread Source/NSView.m

@fredkiefer fredkiefer left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Very impressive code. My main concern is, whether there is any performance impact on applications not using this feature?
And as this is a real mayor contribution, I would like to leave it to @gcasa whether we merge that now.

@DTW-Thalion

DTW-Thalion commented Aug 16, 2026 •

Copy link
Copy Markdown
Contributor Author

I don't see it being particularly performance degrading - the code is actually reasonably light for the feature, and if it was a performance issue, I would be more inclined to look at the backend.

I will make one change to -setFrame: and -setFrameSize: that will save about 5 ns per call, but it is on a hot path - other than that nothing else I can see that is costly.

Also will change the -addConstraint: -removeConstraint: per your suggestion.

NSView implements -removeConstraint: and -removeConstraints:, but neither
is declared, so an application cannot call either one without a warning.
-addConstraints: and -removeConstraints: reached an optimal tableau and
read the solution back once per constraint in the group.  The solver
constraints of a group are now collected and handed to the solver
together, and the tracked views are measured against the result once.
-setFrame: and -setFrameSize: sent -_autoLayoutContentViewResized on
every view, which sends -window and -contentView before it can return.
A process that has created no layout engine now stops at a single test.
@DTW-Thalion

Copy link
Copy Markdown
Contributor Author

Measured on a driver that creates no constraints at all. 200000
-setFrameSize: calls on ordinary subviews cost about 1.2 ms more than master,
near 6 ns a call, all of it -_autoLayoutContentViewResized sending -window and
-contentView before it can return. Nothing else moved: 5000 views entering a
window, 200000 view creations, and content view resizing all measured the same
either way. Minima of 5 interleaved runs, with a control row to catch drift.

The resize path now tests a flag set when the first layout engine is created,
which puts it back at master's cost. The install call at NSView.m:394 cannot
take that guard. A constraint activated before its view has a window is
installed there, and adding it is what creates the engine, so testing for an
existing engine would drop it. That call measured free, since it walks an
array that is empty until a constraint is activated.

Constraints are now added and removed in one solver pass. The engine handed
them to the solver one at a time, so the split had to reach it as well. A group
now collects its solver constraints and reads the solution back once.
Tests/gui/NSLayoutConstraint/batch.m covers both paths against the same
constraints applied singly, and fails without the change.

NSView.h now declares the two removal methods, which were implemented but
missing from the header.

@fredkiefer

Copy link
Copy Markdown
Member

Sounds great, thank you. I review in the morning!

@DTW-Thalion

Copy link
Copy Markdown
Contributor Author

One note, +activateConstraints: still loops singular, so the batch path is only reached via [view addConstraints:]. Making the modern path batch means grouping constraints by container view - I did not do that, but can if you prefer.

@fredkiefer fredkiefer left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Now the code looks even better. Thank you very much!

@fredkiefer
fredkiefer merged commit a0415c9 into gnustep:master Aug 17, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

NSLayoutConstraint activation does not register with the layout engine (setActive:/activateConstraints: are no-ops)

2 participants