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
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"date": "2026-10-13", "area": "mdl/dmlayout", "symptom": "After `mxcli layout`, entity boxes overlapped in Studio Pro: a tall box stacked above a short one ran into it, and `mxcli layout --only-overlapping` reported nothing to move for real collisions (a 22-attribute entity at (100,380) running into one at (100,660); a 45-attribute entity at (880,100) into one at (880,660)).", "cause": "dmlayout assumed an entity's Location is the CENTRE of its box (the package comment cited RelativeMiddlePoint). That field belongs to microflow objects; a domain-model entity stores Location and Studio Pro 11.12.5 draws it as the box's TOP-LEFT corner (entities created at the same Y with 4, 30 and 45 attributes share one top edge). Plan added half a box to every position and the overlap check centred each box, so both were half a box off in x and y.", "file": "mdl/dmlayout/dmlayout.go (Plan), mdl/dmlayout/overlap.go (entityRect, nearestFree)", "fix": "Plan places each entity at the corner of its slot (no half-box); entityRect and nearestFree treat Location as the top-left corner, and a mover is rejected only for negative coordinates. Annotation obstacles keep their both-conventions cover because that convention is unverified.", "test": "TestResolveOverlaps_TopLeftConventionFixture; TestPlan_TallEntityAboveShortOneDoesNotOverlap (mdl/dmlayout)", "insight": "**A coordinate convention is a fact about the file format, so verify it against the application, not against a similarly named field**: `RelativeMiddlePoint` (centre) is the microflow-object field, an entity's `Location` is its top-left. Cheap check: create entities at the same Location.Y with different attribute counts and look at whether the top edges or the centres line up; a test that only agrees with the code's own convention proves nothing, so the regression test sizes boxes independently with the measured geometry (150 wide, 34 + 15 per attribute)."}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"date": "2026-10-13", "area": "mdl/executor", "symptom": "View entities, external entities and entities imported with `create external entities from` were placed on one row (X = 100 + n*150, Y = 100), 150px apart: a 40-entity module was a 6,000px line and the entity boxes touched. CREATE ENTITY had already moved to a wrapping grid.", "cause": "dmlayout.GridSlot replaced the single-row formula in the CREATE ENTITY handler only; three other creation paths kept a private copy of the old formula, and the contract import counted only the entities it had created itself (created+updated), ignoring the entities the module already held.", "file": "mdl/executor/cmd_entities.go (execCreateViewEntity), mdl/executor/cmd_odata.go (execCreateExternalEntity), mdl/executor/cmd_contract.go (createExternalEntities)", "fix": "All three call dmlayout.GridSlot(n) with n = entities already in the domain model; the contract import uses the count read before its loop plus the entities created so far (a created entity is not in that domain model value).", "test": "TestCreateViewEntity_DefaultPositionIsTheGridSlot; TestCreateExternalEntity_DefaultPositionIsTheGridSlot; TestCreateExternalEntities_DefaultPositionIsTheGridSlot (mdl/executor/entity_default_position_test.go)", "insight": "**When a default moves into a shared helper, grep for the old formula's literal constants, not the old function**: `grep -rn '\\* *150' mdl/executor --include=*.go` finds the stragglers in seconds, while a search for the call site of the helper never will. The mock backend appends to dm.Entities on CreateEntity but the real one does not mutate the value it handed out, so a count taken inside the loop is right in the test and wrong in production; read it once before the loop."}
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

### Added

- **`mxcli layout --only-overlapping` moves only the entities whose boxes collide** — the default grid gives every entity the same 280px row, but a box is as tall as its attribute list, so an entity with about 17 or more attributes ran into the one below it, and the only repair was `mxcli layout`, which replaces every position in the module, including the ones set by hand. With the flag, the command finds the entities whose estimated boxes (the size estimate the full layout uses) intersect each other or a domain-model annotation, keeps the one that comes first in the model, and moves each other colliding entity to the nearest free spot (a 40px margin from every box, never above or left of the origin). Every entity that collides with nothing keeps its position exactly. `--dry-run` lists each collision and each move; a second run reports `no overlapping entities` and writes nothing. `--module` and `--include-marketplace` work as before.

### Fixed

- **`mxcli layout` no longer stacks a tall entity into the one below it** — the layout treated an entity's position as the centre of its box, but Studio Pro draws it as the top-left corner (the centre convention, `RelativeMiddlePoint`, belongs to microflow objects), so every placed box sat half its size lower and further right than planned and a tall box ran into a short one under it.
- **View entities, external entities and entities imported from a contract take the grid position `create entity` does** — all three still used the old single-row default (`X: 100 + n*150, Y: 100`) that the grid replaced because 150px is narrower than an entity box: a module of 40 of them was a 6,000px line with the boxes touching. They now take the next `GridSlot` after the entities the module already holds, and an `@Position` is still honoured. Before, `create external entities from` counted only the entities it had created itself, so the first imported entity could land on one the module already had.

## [0.26.0] - 2026-10-11

Headline: **Documents mxcli writes now survive Studio Pro's merge and diff: every element carries exactly the property names Studio Pro writes for the project's Mendix version.** Before, a key mxcli left out, or wrote for a newer version, made a document unmergeable the first time Studio Pro saved it, although `mx check`, the build and the runtime stayed green. `mxcli fix property-sets` repairs projects an older mxcli wrote. Alongside it: a Claude Code companion mod with a Project pane, `describe` → `exec` round trips that keep loop back-edges, shared error handlers and page design properties, `check -p` resolving script sets and security references the way `exec` does, `mxcli run --local` as a background service, and a run of Windows fixes for the local dev loop. The [language poster](https://www.mxcli.org/poster/mxcli-language-poster.html) puts all of mxcli and MDL on one page.
Expand Down
120 changes: 90 additions & 30 deletions cmd/mxcli/cmd_layout.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@ package main

import (
"fmt"
"io"
"os"
"sort"
"strings"

"github.com/mendixlabs/mxcli/mdl/dmlayout"
"github.com/mendixlabs/mxcli/model"
"github.com/mendixlabs/mxcli/sdk/domainmodel"
"github.com/spf13/cobra"
)

Expand All @@ -31,6 +33,7 @@ var (
layoutModules []string
layoutDryRun bool
layoutIncludeMarketplace bool
layoutOnlyOverlapping bool
)

var layoutCmd = &cobra.Command{
Expand All @@ -53,10 +56,21 @@ This REPLACES the positions of every entity in the modules it touches, including
any you arranged by hand. Use --dry-run to see the moves first. Marketplace
modules and System are never touched.

With --only-overlapping nothing is re-arranged. The command finds the entities
whose boxes collide (a box is estimated from the entity's name and attribute
count, as in the full layout; a domain-model annotation counts as an obstacle),
keeps the one that comes first in the model, and moves each other colliding
entity to the nearest free spot. Every entity that collides with nothing keeps
its position exactly, so a diagram arranged by hand is repaired, not replaced.
Running it again finds nothing to move. This is the fix for the default grid,
where an entity with many attributes is taller than a grid row and runs into
the one below.

To re-arrange microflows and nanoflows, use 'mxcli layout flows'.`,
Example: ` mxcli layout -p app.mpr
mxcli layout -p app.mpr --module CapTrack
mxcli layout -p app.mpr --dry-run`,
mxcli layout -p app.mpr --dry-run
mxcli layout -p app.mpr --module Sales --only-overlapping --dry-run`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
return runLayout(cmd)
Expand All @@ -70,6 +84,8 @@ func init() {
"report the moves without writing")
layoutCmd.Flags().BoolVar(&layoutIncludeMarketplace, "include-marketplace", false,
"also lay out Marketplace modules (a module update replaces them, so this is normally pointless)")
layoutCmd.Flags().BoolVar(&layoutOnlyOverlapping, "only-overlapping", false,
"move only entities whose boxes collide with another entity or annotation (to the nearest free spot); all other positions are kept")
rootCmd.AddCommand(layoutCmd)
}

Expand Down Expand Up @@ -118,51 +134,95 @@ func runLayout(cmd *cobra.Command) error {
continue
}

plan := dmlayout.Plan(dm)
moved := 0
var moves []string
for _, e := range dm.Entities {
p, ok := plan[e.ID]
if !ok || p == e.Location {
continue
}
moves = append(moves, fmt.Sprintf(" %s.%s (%d, %d) -> (%d, %d)",
m.Name, e.Name, e.Location.X, e.Location.Y, p.X, p.Y))
if !layoutDryRun {
e.Location = p
update := func(dm *domainmodel.DomainModel) error {
if err := b.UpdateDomainModel(dm); err != nil {
return fmt.Errorf("write domain model of %s: %w", m.Name, err)
}
moved++
return nil
}
moved, err := layoutModule(cmd.OutOrStdout(), m.Name, dm, layoutDryRun, layoutOnlyOverlapping, update)
if err != nil {
return err
}
sort.Strings(moves)

if moved == 0 {
unchangedTotal += len(dm.Entities)
fmt.Fprintf(cmd.OutOrStdout(), "%s: already laid out (%d entities)\n", m.Name, len(dm.Entities))
continue
}
for _, line := range moves {
fmt.Fprintln(cmd.OutOrStdout(), line)
}
if layoutDryRun {
fmt.Fprintf(cmd.OutOrStdout(), "%s: %d of %d entities would move\n", m.Name, moved, len(dm.Entities))
} else {
if err := b.UpdateDomainModel(dm); err != nil {
return fmt.Errorf("write domain model of %s: %w", m.Name, err)
}
fmt.Fprintf(cmd.OutOrStdout(), "%s: moved %d of %d entities\n", m.Name, moved, len(dm.Entities))
}
movedTotal += moved
}

switch {
case movedTotal == 0 && unchangedTotal == 0:
fmt.Fprintln(cmd.OutOrStdout(), "Nothing to lay out.")
case layoutDryRun:
case layoutDryRun && (movedTotal > 0 || !layoutOnlyOverlapping):
fmt.Fprintf(cmd.OutOrStdout(), "Dry run: %d entities would move. Re-run without --dry-run to apply.\n", movedTotal)
}
return nil
}

// layoutModule positions the entities of one domain model, prints what it did
// and, unless dryRun, hands the changed model to update. It returns how many
// entities moved.
//
// The full layout (Plan) positions every entity from the association graph.
// With onlyOverlapping it instead moves only the entities whose estimated boxes
// collide (dmlayout.ResolveOverlaps) and leaves every other Location untouched.
func layoutModule(out io.Writer, module string, dm *domainmodel.DomainModel, dryRun, onlyOverlapping bool,
update func(*domainmodel.DomainModel) error) (int, error) {
to := map[model.ID]model.Point{}
if onlyOverlapping {
plan := dmlayout.ResolveOverlaps(dm)
for _, p := range plan.Pairs {
with := module + "." + p.With
if p.WithAnnotation {
with = fmt.Sprintf("annotation %q", p.With)
}
fmt.Fprintf(out, " %s.%s overlaps %s\n", module, p.Entity, with)
}
for _, mv := range plan.Moves {
to[mv.ID] = mv.To
}
} else {
to = dmlayout.Plan(dm)
}

moved := 0
var moves []string
for _, e := range dm.Entities {
p, ok := to[e.ID]
if !ok || p == e.Location {
continue
}
moves = append(moves, fmt.Sprintf(" %s.%s (%d, %d) -> (%d, %d)",
module, e.Name, e.Location.X, e.Location.Y, p.X, p.Y))
if !dryRun {
e.Location = p
}
moved++
}
sort.Strings(moves)

if moved == 0 {
if onlyOverlapping {
fmt.Fprintf(out, "%s: no overlapping entities (%d entities)\n", module, len(dm.Entities))
} else {
fmt.Fprintf(out, "%s: already laid out (%d entities)\n", module, len(dm.Entities))
}
return 0, nil
}
for _, line := range moves {
fmt.Fprintln(out, line)
}
if dryRun {
fmt.Fprintf(out, "%s: %d of %d entities would move\n", module, moved, len(dm.Entities))
return moved, nil
}
if err := update(dm); err != nil {
return 0, err
}
fmt.Fprintf(out, "%s: moved %d of %d entities\n", module, moved, len(dm.Entities))
return moved, nil
}

// layoutTargets picks the modules to touch.
//
// A Marketplace module is excluded by default for the same reason CREATE LAYOUT
Expand Down
104 changes: 104 additions & 0 deletions cmd/mxcli/cmd_layout_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@
package main

import (
"bytes"
"fmt"
"strings"
"testing"

"github.com/mendixlabs/mxcli/model"
"github.com/mendixlabs/mxcli/sdk/domainmodel"
)

func mod(name string, marketplace bool) *model.Module {
Expand Down Expand Up @@ -132,3 +135,104 @@ func TestLayoutTargets_IsSorted(t *testing.T) {
t.Errorf("targets = %v, want them sorted", names)
}
}

// overlapFixture is a domain model as CREATE ENTITY leaves it when one entity is
// longer than a grid row: Order (30 attributes) runs into Invoice below it.
// Pinned sits far from both, off any grid.
func overlapFixture() *domainmodel.DomainModel {
attrs := func(n int) []*domainmodel.Attribute {
out := make([]*domainmodel.Attribute, n)
for i := range out {
out[i] = &domainmodel.Attribute{Name: fmt.Sprintf("Attr%d", i)}
}
return out
}
mk := func(name string, n, x, y int) *domainmodel.Entity {
e := &domainmodel.Entity{Name: name, Attributes: attrs(n), Location: model.Point{X: x, Y: y}}
e.ID = model.ID("id-" + name)
return e
}
return &domainmodel.DomainModel{Entities: []*domainmodel.Entity{
mk("Order", 30, 100, 100),
mk("Invoice", 3, 100, 380),
mk("Pinned", 4, 4321, 1234),
}}
}

func runLayoutModule(t *testing.T, dm *domainmodel.DomainModel, dryRun, only bool) (string, int, int) {
t.Helper()
var out bytes.Buffer
writes := 0
moved, err := layoutModule(&out, "Sales", dm, dryRun, only, func(*domainmodel.DomainModel) error {
writes++
return nil
})
if err != nil {
t.Fatal(err)
}
return out.String(), moved, writes
}

// --dry-run lists the colliding pair and the move, and neither writes nor
// changes the model.
func TestLayoutOnlyOverlapping_DryRunListsPairsAndMoves(t *testing.T) {
dm := overlapFixture()
before := fmt.Sprint(dm.Entities[0].Location, dm.Entities[1].Location, dm.Entities[2].Location)
out, moved, writes := runLayoutModule(t, dm, true, true)

for _, want := range []string{
"Sales.Invoice overlaps Sales.Order",
"Sales.Invoice (100, 380) -> (",
"Sales: 1 of 3 entities would move",
} {
if !strings.Contains(out, want) {
t.Errorf("output lacks %q:\n%s", want, out)
}
}
if strings.Contains(out, "Sales.Pinned (") || strings.Contains(out, "Sales.Order (") {
t.Errorf("an entity that keeps its place was listed as a move:\n%s", out)
}
if moved != 1 || writes != 0 {
t.Errorf("moved=%d writes=%d, want 1 and 0 on a dry run", moved, writes)
}
if after := fmt.Sprint(dm.Entities[0].Location, dm.Entities[1].Location, dm.Entities[2].Location); after != before {
t.Errorf("a dry run changed the model: %s -> %s", before, after)
}
}

// A real run moves only the colliding entity, writes once, and the next run has
// nothing to do.
func TestLayoutOnlyOverlapping_MovesOnlyTheCollidingEntityAndIsIdempotent(t *testing.T) {
dm := overlapFixture()
order, pinned := dm.Entities[0].Location, dm.Entities[2].Location
invoice := dm.Entities[1].Location

out, moved, writes := runLayoutModule(t, dm, false, true)
if moved != 1 || writes != 1 || !strings.Contains(out, "Sales: moved 1 of 3 entities") {
t.Fatalf("first run: moved=%d writes=%d\n%s", moved, writes, out)
}
if dm.Entities[0].Location != order || dm.Entities[2].Location != pinned {
t.Error("an entity that collides with nothing was moved")
}
if dm.Entities[1].Location == invoice {
t.Error("Invoice was not moved")
}

out, moved, writes = runLayoutModule(t, dm, false, true)
if moved != 0 || writes != 0 || !strings.Contains(out, "Sales: no overlapping entities (3 entities)") {
t.Errorf("second run: moved=%d writes=%d\n%s", moved, writes, out)
}
}

// CONTROL: without the flag the command is the full layout, as before.
func TestLayoutWithoutOnlyOverlapping_StillArrangesEverything(t *testing.T) {
dm := overlapFixture()
_, moved, writes := runLayoutModule(t, dm, false, false)
if moved != 3 || writes != 1 {
t.Errorf("full layout moved %d of 3 (writes=%d), want all three", moved, writes)
}
out, moved, writes := runLayoutModule(t, dm, false, false)
if moved != 0 || writes != 0 || !strings.Contains(out, "already laid out") {
t.Errorf("second full layout: moved=%d writes=%d\n%s", moved, writes, out)
}
}
Loading
Loading