GroupingTile.java
/* ========================================================================
* PlantUML : a free UML diagram generator
* ========================================================================
*
* (C) Copyright 2009-2024, Arnaud Roques
*
* Project Info: https://plantuml.com
*
* If you like this project or if you find it useful, you can support us at:
*
* https://plantuml.com/patreon (only 1$ per month!)
* https://plantuml.com/paypal
*
* This file is part of PlantUML.
*
* PlantUML is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* PlantUML distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public
* License for more details.
*
* You should have received a copy of the GNU General Public
* License along with this library; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301,
* USA.
*
*
* Original Author: Arnaud Roques
*
*
*/
package net.sourceforge.plantuml.sequencediagram.teoz;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import net.sourceforge.plantuml.asciiverse.ADimension2D;
import net.sourceforge.plantuml.asciiverse.AElseSeparator;
import net.sourceforge.plantuml.asciiverse.AGroupFrame;
import net.sourceforge.plantuml.asciiverse.InfinitePlan;
import net.sourceforge.plantuml.klimt.UTranslate;
import net.sourceforge.plantuml.klimt.color.HColor;
import net.sourceforge.plantuml.klimt.creole.Display;
import net.sourceforge.plantuml.klimt.drawing.UGraphic;
import net.sourceforge.plantuml.klimt.font.StringBounder;
import net.sourceforge.plantuml.klimt.geom.XDimension2D;
import net.sourceforge.plantuml.klimt.shape.UDrawable;
import net.sourceforge.plantuml.real.Real;
import net.sourceforge.plantuml.real.RealUtils;
import net.sourceforge.plantuml.sequencediagram.Event;
import net.sourceforge.plantuml.sequencediagram.Grouping;
import net.sourceforge.plantuml.sequencediagram.GroupingLeaf;
import net.sourceforge.plantuml.sequencediagram.GroupingStart;
import net.sourceforge.plantuml.sequencediagram.GroupingType;
import net.sourceforge.plantuml.sequencediagram.HSpace;
import net.sourceforge.plantuml.sequencediagram.LifeEvent;
import net.sourceforge.plantuml.sequencediagram.Message;
import net.sourceforge.plantuml.sequencediagram.MessageExo;
import net.sourceforge.plantuml.sequencediagram.Note;
import net.sourceforge.plantuml.sequencediagram.NotePosition;
import net.sourceforge.plantuml.skin.Area;
import net.sourceforge.plantuml.skin.Component;
import net.sourceforge.plantuml.skin.ComponentType;
import net.sourceforge.plantuml.skin.Context2D;
import net.sourceforge.plantuml.skin.rose.Rose;
import net.sourceforge.plantuml.style.ISkinParam;
import net.sourceforge.plantuml.style.PName;
import net.sourceforge.plantuml.style.Style;
public class GroupingTile extends AbstractTile {
public static final int EXTERNAL_MARGINX1 = 3;
public static final int EXTERNAL_MARGINX2 = 9;
// Vertical breathing room around a group frame, on each side. It is reserved
// in getPreferredHeight() (both sides) and the TOP one is applied at draw time
// via getFrameY() -- deliberately NOT by offsetting the gauge min, which must
// stay a clean chaining point for parallel (&) siblings.
public static final int EXTERNAL_MARGINY = 4;
private static final int MARGINX = 16;
// private static final int MARGINY = 10;
private static final int MARGINY_MAGIC = 20;
private final List<Tile> tiles = new ArrayList<>();
private final Real min;
private final Real max;
private final GroupingStart start;
private GroupingLeaf end;
private final YGauge yGauge;
private final Rose skin;
private final ISkinParam skinParam;
private final Display display;
private double bodyHeight;
private final TileArguments tileArguments;
public Event getEvent() {
return start;
}
@Override
public double getContactPointRelative() {
return 0;
}
@Override
public YGauge getYGauge() {
return yGauge;
}
public GroupingTile(Iterator<Event> it, GroupingStart start, TileArguments tileArgumentsBackColorChanged,
TileArguments tileArgumentsOriginal, YGauge currentY) {
super(tileArgumentsBackColorChanged.getStringBounder(), currentY);
this.tileArguments = tileArgumentsOriginal;
final StringBounder stringBounder = tileArgumentsOriginal.getStringBounder();
this.start = start;
this.display = start.getTitle().equals("group") ? Display.create(start.getComment())
: Display.create(start.getTitle(), start.getComment());
this.skin = tileArgumentsOriginal.getSkin();
// this.skinParam = tileArgumentsOriginal.getSkinParam();
this.skinParam = tileArgumentsBackColorChanged.getSkinParam();
final List<Real> min2 = new ArrayList<>();
final List<Real> max2 = new ArrayList<>();
final List<Tile> allElses = new ArrayList<>();
final XDimension2D dim1 = getPreferredDimensionIfEmpty(stringBounder);
// Chain anchoring for a whole & -parallel group (see the CHAIN
// ANCHORING note on YGauge): a group following "& opt"/"& alt" must start at
// the PREVIOUS tile's min (top-aligned, like CommunicationTile does
// for a plain parallel message), not its max (which would stack it
// below, sequentially). previousMax is kept aside so the outer chain's
// max can still be pushed past whichever of the two members is taller.
final Real previousMax = currentY.getMax();
final boolean parallel = start.isParallel();
// INVARIANT: the gauge's min is the CHAINING POINT, never the drawn
// frame's top. Those two differ by EXTERNAL_MARGINY for a group, and
// conflating them broke `&` groups: a parallel sibling chains on the
// previous group's min, so if that min were already margin-offset the
// sibling would inherit the offset AND add its own -- drifting 4px lower
// per pair. The margin is applied at DRAW time instead (see getFrameY() /
// drawU), so the min stays a clean chaining point siblings can align on.
final Real firstY = parallel ? currentY.getMin() : previousMax;
// The body starts below the frame's top margin AND the header
final double h = dim1.getHeight() + MARGINY_MAGIC / 2 + EXTERNAL_MARGINY;
currentY = YGauge.create(firstY.addAtLeast(h), 0);
while (it.hasNext()) {
final Event ev = it.next();
if (ev instanceof GroupingLeaf && ((Grouping) ev).getType() == GroupingType.END) {
this.end = (GroupingLeaf) ev;
break;
}
for (Tile tile : TileBuilder.buildOne(it, tileArgumentsOriginal, ev, this, currentY)) {
tiles.add(tile);
currentY = tile.getYGauge();
}
}
// bodyHeight drives both the frame's own drawn height (getTotalHeight)
// and the space this GroupingTile reserves in the OUTER Y chain
// (getPreferredHeight() -> this.yGauge below): it must count each
// parallel (&) cluster ONCE (tallest-before-contact + tallest-after-
// contact), not sum every member's own height, or the group ends up
// taller than needed whenever it contains a & message.
//
// Y POSITIONING itself no longer goes through this clustering (the gauge
// chain does it, at construction time), so `tiles` stays FLAT for drawing;
// this is a throwaway, height-only pass over the same list.
bodyHeight = computeBodyHeight(tiles);
// `tiles` is FLAT (the & clustering is height-only, see bodyHeight above),
// so every else tile is reached directly here
for (Tile tile : tiles) {
final Event ev = tile.getEvent();
if (ev instanceof GroupingLeaf && ((Grouping) ev).getType() == GroupingType.ELSE) {
allElses.add(tile);
continue;
}
// A `|||` (HSpace) is a pure vertical spacer with no horizontal position of
// its own: HSpaceTile.getMinX()/getMaxX() just return the diagram's global
// X origin (TileArguments.getXOrigin()), unrelated to the participants used
// inside this group. Folding that value into min2/max2 dragged the group's
// frame all the way to the left edge of the diagram whenever a `|||`
// appeared inside a group (issue #2789), so exclude it from the bounding
// box computation, exactly like an else section is excluded above.
if (ev instanceof HSpace)
continue;
min2.add(tile.getMinX().addFixed(-MARGINX));
final Real m = tile.getMaxX();
// max2.add(m == tileArgumentsOriginal.getOmega() ? m : m.addFixed(MARGINX));
max2.add(m.addFixed(MARGINX));
}
final double width = dim1.getWidth();
if (min2.size() == 0)
min2.add(tileArgumentsOriginal.getXOrigin());
this.min = RealUtils.min(min2);
for (Tile anElse : allElses)
max2.add(anElse.getMaxX());
max2.add(this.min.addFixed(width + 16));
this.max = RealUtils.max(max2);
// min == chaining point (NOT the frame top -- see firstY above).
// getPreferredHeight() covers the frame plus BOTH margins, so the max is
// simply min + getPreferredHeight().
if (parallel) {
// Like YGauge.createParallel: the group's own max must still
// dominate the PREVIOUS sibling's max, so that whichever of the
// two parallel groups is taller correctly pushes the next
// sequential tile below the whole pair, not just below this one.
final Real parallelMax = firstY.addAtLeast(getPreferredHeight());
parallelMax.ensureBiggerThan(previousMax);
this.yGauge = new YGauge(firstY, parallelMax);
} else {
this.yGauge = new YGauge(firstY, firstY.addAtLeast(getPreferredHeight()));
}
}
// Absolute Y of the DRAWN frame's top edge: the gauge min (the chaining
// point) plus the top margin. Everything that draws the frame or positions
// something relative to it must go through this, never through the raw
// gauge min.
private double getFrameY() {
return getYGauge().getMin().getCurrentValue() + EXTERNAL_MARGINY;
}
protected Component getComponent(StringBounder stringBounder) {
final Component comp = skin.createComponent(start.getUsedStyles(), ComponentType.GROUPING_HEADER_TEOZ, null,
skinParam, display);
return comp;
}
private XDimension2D getPreferredDimensionIfEmpty(StringBounder stringBounder) {
return getComponent(stringBounder).getPreferredDimension(stringBounder);
}
@Override
public void drawU(UGraphic ug) {
final StringBounder stringBounder = ug.getStringBounder();
final Area area = getArea(stringBounder);
final Component comp = getComponent(stringBounder);
if (((Context2D) ug).isBackground())
drawBackground(ug, area);
// The frame is drawn EXTERNAL_MARGINY below the gauge min (which is the
// chaining point, not the frame top -- see getFrameY)
comp.drawU(ug.apply(new UTranslate(minCurrentValueForDrawing(), getFrameY())), area, (Context2D) ug);
drawNotes(ug.apply(UTranslate.dy(getFrameY())));
// Children draw themselves in ABSOLUTE coordinates (each applies its own
// gauge min as a self-translate prologue), so no running dy() is accumulated
// here. The else dividers are drawn by the ElseTiles themselves, likewise.
for (Tile tile : tiles)
((UDrawable) tile).drawU(ug);
}
protected Area getArea(final StringBounder stringBounder) {
final Area area = Area.create(max.getCurrentValue() - min.getCurrentValue(), getTotalHeight(stringBounder));
return area;
}
protected double minCurrentValueForDrawing() {
return min.getCurrentValue();
}
// Frame extents (the group's own border), as opposed to getMinX()/getMaxX()
// which additionally reserve EXTERNAL_MARGINX1/2 and the width of notes
// attached to the group's "end" ("note left"/"note right" after `end`) --
// that reservation is for the PARENT's layout only. Children that draw
// something spanning the frame itself (the else divider line, the
// background Blotter) must use these, not getMinX()/getMaxX(), or they
// draw wider than the visible frame whenever a group-end note is present.
final Real getFrameMinX() {
return min;
}
final Real getFrameMaxX() {
return max;
}
private void drawBackground(UGraphic ug, Area area) {
final Style style = start.getUsedStyles()[0];
final HColor back = style.value(PName.BackGroundColor).asColor(skinParam.getIHtmlColorSet());
final double round = style.value(PName.RoundCorner).asDouble();
// `ug` arrives UNTRANSLATED: every tile self-translates via its own absolute
// gauge. So the Blotter -- which draws its bands from a LOCAL y=0 -- must be
// fed a `ug` explicitly translated to the frame top, not just an x offset.
drawCompBackground(ug.apply(UTranslate.dy(getFrameY())), area, back, round);
}
protected void drawCompBackground(UGraphic ug, Area area, final HColor back, final double round) {
final XDimension2D dimensionToUse = area.getDimensionToUse();
final Blotter blotter = new Blotter(dimensionToUse, back, round);
for (Tile tile : tiles)
if (tile instanceof ElseTile) {
final ElseTile elseTile = (ElseTile) tile;
// The color boundary must land EXACTLY where ElseTile.drawU() draws
// its dashed divider, i.e. at the else's gauge min with NO extra
// offset. (The pre-YGauge code carried a `+ MARGINY_MAGIC / 2` here,
// but that was only harmless because the divider line was drawn by
// the same method using the identical "+10", so the two cancelled out.
// Now the divider is drawn independently by the ElseTile itself, so
// carrying that term over would sit the band 10px off.)
final double ypos = elseTile.getYGauge().getMin().getCurrentValue() - getFrameY();
HColor backElse = elseTile.getBackColorGeneral();
// An else section without its own color inherits the group
// background: it may come from the style, not only from the
// inline #color of the command, so getBackColorGeneral() alone
// is not enough
if (backElse == null)
backElse = back;
blotter.addChange(ypos + 1, backElse);
}
blotter.closeChanges();
// `ug` was already translated to the frame top by the caller (see
// drawBackground above), so only the X offset is needed here.
blotter.drawU(ug.apply(UTranslate.dx(min.getCurrentValue())));
}
final protected double getTotalHeight(StringBounder stringBounder) {
final XDimension2D dimIfEmpty = getPreferredDimensionIfEmpty(stringBounder);
return bodyHeight + dimIfEmpty.getHeight() + MARGINY_MAGIC / 2;
}
@Override
public double getPreferredHeight() {
final XDimension2D dim1 = getPreferredDimensionIfEmpty(getStringBounder());
// Both vertical margins are RESERVED here, so the gauge spans the frame plus
// its two margins and the next tile chains clear of it. The TOP margin is
// additionally materialized at draw time (the frame is drawn at getFrameY()
// == gauge min + EXTERNAL_MARGINY), which is what actually moves the border
// down. Reserving without drawing the offset leaves the frame flush against
// the previous tile -- and, right after a `newpage`, flush against the page
// clip boundary, so the top border bleeds into the previous page.
return dim1.getHeight() + bodyHeight + MARGINY_MAGIC + 2 * EXTERNAL_MARGINY;
}
public void addConstraints() {
for (Tile tile : tiles)
tile.addConstraints();
ensureFollowingParticipantClearsFrame();
}
// A child tile only reserves room for ITS OWN footprint against its
// immediate neighbour (e.g. CommunicationTileSelf.addConstraints() pushes
// the participant declared right after it just past its own self-message
// loop). None of them know that this GroupingTile's frame extends a
// further MARGINX + EXTERNAL_MARGINX2 beyond whatever their own footprint
// is (see the min/max computation in the constructor above) -- so a
// participant declared right after the group, but never itself used
// inside it, can end up placed UNDER the group's frame whenever no child
// constraint happened to reserve enough room on its own (typically: the
// group's last-touched participant ends on a self-message, whose own
// loop is narrower than "loop width + group frame margin") (issue #2788).
// Explicitly push the very next participant (in declaration order) past
// this group's own resolved right edge; the existing sequential
// LivingSpaces.addConstraints() 10px-gap chain takes care of every
// participant further right, once this first one is clear.
//
// CRITICAL, same trap as PlayingSpace.ensureDisjoint() (see its own comment):
// this must NEVER feed `this.max` -- or any other RealUtils.max()/min()
// composed Real, e.g. a plain CommunicationTile.getMaxX() -- into an
// ensureBiggerThan() call. RealMax/RealMin cache their resolved value the
// FIRST time getCurrentValue() is read, permanently, with no invalidation.
// A force's apply() reads both its endpoints on EVERY compile() pass, so a
// RealMax handed to ensureBiggerThan() gets read (and frozen) on pass one --
// typically before the dependents it aggregates (e.g. a message's target
// participant, still being pushed rightward by that message's OWN,
// separately-registered force) have converged. The frame then draws at the
// stale, too-small frozen width while the participant it was supposed to
// include keeps moving right on later passes, right out of the frame (this
// exact bug, shipped once already: an `alt`/`else` branch using a plain,
// non-self message to a participant not touched by the `if` branch
// rendered outside the frame because this method used to pass `max`
// directly). The fix below only ever reads plain LivingSpace positions
// (posC, a chained RealDelta over posB, never cached) and, for a
// self-message, CommunicationTileSelf.getMaxX() (also a plain delta chain,
// confirmed by inspection to never construct a RealUtils.max()) -- both
// safe to re-read on every compile() pass.
private void ensureFollowingParticipantClearsFrame() {
final LivingSpaces livingSpaces = tileArguments.getLivingSpaces();
final Set<LivingSpace> touched = new HashSet<>();
final Map<LivingSpace, List<Tile>> selfMessagesByLivingSpace = new HashMap<>();
collectTouchedLivingSpaces(tiles, livingSpaces, touched, selfMessagesByLivingSpace);
LivingSpace rightmost = null;
for (LivingSpace candidate : livingSpaces.values())
if (touched.contains(candidate))
rightmost = candidate;
if (rightmost == null)
return;
final LivingSpace next = livingSpaces.next(rightmost);
if (next == null)
return;
final Real nextPosA = next.getPosA(getStringBounder());
final double frameMargin = MARGINX + EXTERNAL_MARGINX2;
// Baseline: the rightmost touched participant's own lifeline, plus the
// frame's margin. For a normal (non-self) message this already matches
// the frame's true edge once the graph stabilizes: the message's own
// addConstraints() guarantees its target's posC clears the arrow+label,
// so the target's posC alone dominates the same RealUtils.max() the
// constructor would otherwise have computed.
nextPosA.ensureBiggerThan(rightmost.getPosC(getStringBounder()).addFixed(frameMargin));
// A self-message ending on the rightmost participant reaches further
// right than its own posC (that's the whole shape of issue #2788) --
// each one found gets its own extra push, safe to add independently
// since ensureBiggerThan() calls simply stack (the solver satisfies all
// of them, which is exactly the "max" this deliberately avoids computing
// via RealUtils.max()).
final List<Tile> selfMessages = selfMessagesByLivingSpace.get(rightmost);
if (selfMessages != null)
for (Tile tile : selfMessages)
nextPosA.ensureBiggerThan(tile.getMaxX().addFixed(frameMargin));
}
// Walks this group's tiles (and any nested groups) to find every
// LivingSpace touched by a message/self-message/life-event, and
// separately buckets the CommunicationTileSelf tiles by the LivingSpace
// they self-message on (needed above to add their loop width on top of
// the plain posC baseline). Declaration order tracks left-to-right layout
// order (the same assumption PlayingSpace.ensureDisjoint() already relies
// on), so the touched set alone is enough to find "the group's own
// rightmost content" without reading any not-yet-resolved Real position.
private static void collectTouchedLivingSpaces(List<Tile> tiles, LivingSpaces livingSpaces,
Set<LivingSpace> touched, Map<LivingSpace, List<Tile>> selfMessagesByLivingSpace) {
for (Tile tile : tiles) {
final Event ev = tile.getEvent();
if (ev instanceof Message) {
final LivingSpace ls1 = livingSpaces.get(((Message) ev).getParticipant1());
final LivingSpace ls2 = livingSpaces.get(((Message) ev).getParticipant2());
touched.add(ls1);
touched.add(ls2);
if (tile instanceof CommunicationTileSelf)
selfMessagesByLivingSpace.computeIfAbsent(ls1, k -> new ArrayList<>()).add(tile);
} else if (ev instanceof MessageExo) {
touched.add(livingSpaces.get(((MessageExo) ev).getParticipant()));
} else if (ev instanceof LifeEvent) {
touched.add(livingSpaces.get(((LifeEvent) ev).getParticipant()));
}
if (tile instanceof GroupingTile)
collectTouchedLivingSpaces(((GroupingTile) tile).tiles, livingSpaces, touched,
selfMessagesByLivingSpace);
}
}
// ===================== ASCII =====================
//
// A group (frame, alt, opt, loop, partition, ...) draws itself as a frame
// box enclosing its stacked children, with its title on the top border
// row — the same object-oriented "each object draws itself" pattern as
// every other tile, and structurally the ASCII counterpart of the
// pixel drawU() above (frame component + children stacked below the
// header). PartitionTile inherits all of this unchanged.
//
// One margin constant, the ASCII analogue of the pixel MARGINX: how many
// cells the frame border sits outside the leftmost/rightmost column its
// children actually touch, so arrows and lifelines run inside the frame,
// not on its border.
protected static final int ASCII_FRAME_MARGIN = 2;
// The header is the frame's top border, plus (when the title fits) its
// pentagon-style tab — delegated entirely to AGroupFrame.getBodyRowOffset(),
// since it alone decides whether the tab is drawn; the footer is a
// single row (the tilde bottom border). The body in between is the children
// stacked, each at its own asciiDimension() height.
private static final int ASCII_FOOTER_ROWS = 1;
// Builds a throwaway AGroupFrame (same pattern as ANote's size, read back
// rather than recomputed elsewhere) purely to ask how many rows its
// header occupies — needed by both asciiDimension() (to size the frame
// before it's ever drawn) and asciiDraw()'s child-stacking loop. The height
// passed to ADimension2D here is irrelevant (getBodyRowOffset() only reads
// the width), so 0 is fine.
private int asciiHeaderRows() {
final int[] cols = asciiFrameColumns();
final int width = cols[1] - cols[0] + 1;
return new AGroupFrame(new ADimension2D(width, 0), asciiTitle(), asciiFrameHasTab()).getBodyRowOffset();
}
// Whether this group's ASCII frame uses the pentagon-style cut-corner tab
// (true, every plain group/alt/loop/... kind) or stamps its title centered
// directly on the top border instead (false). Overridden by PartitionTile:
// a partition's pixel rendering (PartitionTile.getComponent()) never draws
// a tab, only a plain rectangle with a centered title — see AGroupFrame's
// useTab field for the actual shape difference.
protected boolean asciiFrameHasTab() {
return true;
}
@Override
public void asciiAddConstraints() {
// Exactly like addConstraints() above: recurse into the children so a
// message nested inside the group still pushes its two participants
// apart. The frame's own title width is not (yet) reserved on the
// ASCII column solver — a very long group title could overflow
// the frame past the participants; same class of known gap as notes.
for (Tile tile : tiles)
tile.asciiAddConstraints();
}
// The union of the children's own ascii ranges, expressed as composable
// Real (not yet resolved columns) — exactly the same shape as the pixel
// min/max computed from tile.getMinX()/getMaxX() in the constructor above
// (RealUtils.min(min2)/RealUtils.max(max2)). Unlike the pixel version this
// can't be precomputed in the constructor: the ASCII Real graph
// (asciiPosB...) doesn't exist yet at construction time, only once
// PlayingSpaceWithParticipants.asciiDraw() wires it up — so it's computed
// lazily, on demand, each time it's asked. Children with no range of their
// own (null, e.g. EmptyTile) are skipped; if none has any, null is
// returned, same contract as the Tile default.
private Real asciiChildrenMin() {
final List<Real> mins = new ArrayList<>();
for (Tile tile : tiles) {
final Real m = tile.getAsciiMinX();
if (m != null)
mins.add(m);
}
if (mins.isEmpty())
return null;
return RealUtils.min(mins);
}
private Real asciiChildrenMax() {
final List<Real> maxs = new ArrayList<>();
for (Tile tile : tiles) {
final Real m = tile.getAsciiMaxX();
if (m != null)
maxs.add(m);
}
if (maxs.isEmpty())
return null;
return RealUtils.max(maxs);
}
@Override
public Real getAsciiMinX() {
final Real childMin = asciiChildrenMin();
if (childMin == null)
return null;
return childMin.addFixed(-ASCII_FRAME_MARGIN);
}
@Override
public Real getAsciiMaxX() {
final Real childMax = asciiChildrenMax();
if (childMax == null)
return null;
return childMax.addFixed(ASCII_FRAME_MARGIN);
}
// The [left, right] resolved ASCII columns of the frame border itself,
// read from getAsciiMinX()/getAsciiMaxX() once the ASCII RealLine has
// compiled — the one place in this class where a Real is finally cast to
// an int, since InfinitePlan only understands character columns. Falls
// back to spanning every participant of the diagram when the group has no
// child with any columns (e.g. an empty group), so it still draws a
// sensible frame instead of crashing on a null range.
protected int[] asciiFrameColumns() {
final Real min = getAsciiMinX();
final Real max = getAsciiMaxX();
if (min != null && max != null)
return new int[] { (int) min.getCurrentValue(), (int) max.getCurrentValue() };
// Spans the participant BOXES (posB .. posD - 1), not their lifeline centres:
// same convention as PartitionTile.asciiFrameColumns(), so an empty group and
// a partition frame agree on where the diagram's edges are. (Using the life
// columns here would draw an empty group's frame narrower than the heads it
// is supposed to enclose.)
int lo = Integer.MAX_VALUE;
int hi = Integer.MIN_VALUE;
for (LivingSpace ls : getTileArguments().getLivingSpaces().values()) {
// posB is the box's left column, posD its EXCLUSIVE right edge
lo = Math.min(lo, (int) ls.getAsciiPosB().getCurrentValue());
hi = Math.max(hi, (int) ls.getAsciiPosD().getCurrentValue() - 1);
}
if (lo == Integer.MAX_VALUE)
return new int[] { 0, 0 };
return new int[] { lo - ASCII_FRAME_MARGIN, hi + ASCII_FRAME_MARGIN };
}
// Body height, between the header (top border) and footer (bottom border)
// rows: the last row occupied by any child, per computeAsciiLayout()'s
// clustering -- a `&` run inside the group is counted ONCE (its tallest
// member), not summed member by member, matching computeBodyHeight()'s
// pixel-side fix for the exact same bug. Does NOT catch
// UnsupportedOperationException: a child with no ASCII support crashes
// here, naming itself, exactly like the orchestrator's own policy.
private int asciiBodyHeight() {
return computeAsciiLayout(tiles).totalHeight;
}
@Override
public ADimension2D asciiDimension() {
final int[] cols = asciiFrameColumns();
final int width = cols[1] - cols[0] + 1;
final int height = asciiHeaderRows() + asciiBodyHeight() + ASCII_FOOTER_ROWS;
return new ADimension2D(width, height);
}
// ASCII counterpart of drawU(): delegate the frame box + title to an
// AGroupFrame — the AsciiBlock counterpart of the pixel GROUPING_HEADER_TEOZ
// Component that drawU() obtains from the skin and delegates to —
// positioned at the frame's absolute left column, then
// draw each child inside, stacked, exactly like drawU() translates each
// child by the running height below the header. Children compute their own
// absolute columns from getAsciiLifeColumn() (they ignore the plan's
// horizontal translation for their arrows — see CommunicationTile.asciiDraw()),
// so they are drawn with dx = 0 and only their row advanced; the frame, by
// contrast, is drawn at the absolute columns asciiFrameColumns() resolved
// to. The child-stacking stays here, in the tile, not in the frame block:
// the pixel drawU() likewise stacks children itself below the header
// Component rather than nesting them inside it.
@Override
public void asciiDraw(InfinitePlan plan) {
final int[] cols = asciiFrameColumns();
final int left = cols[0];
final int width = cols[1] - cols[0] + 1;
// The frame draws itself (border, sides, footer, and title tab) at its
// absolute left column; the same instance is reused just below to know
// where the body starts, rather than re-deriving that separately.
final AGroupFrame frame = new AGroupFrame(asciiDimension(), asciiTitle(), asciiFrameHasTab());
frame.asciiDraw(plan.move(left, 0));
// Children stacked in the body, below the header, using the SAME cluster
// layout as asciiBodyHeight() (via computeAsciiLayout()) so the declared
// height and the actual drawn rows agree -- a `&` run's members all start
// at the SAME row instead of stacking sequentially, and the row after the
// run advances by only the tallest member's height, not by every member's
// height added up. An ElseTile is the exception -- its divider spans the
// whole frame, whose columns only this parent knows, so the parent draws
// it via an AElseSeparator (the counterpart of drawAllElses()) at the
// frame's absolute left column and full width. The ElseTile still owns its
// own row height via asciiDimension() (and is never itself a `&` member).
final AsciiLayout layout = computeAsciiLayout(tiles);
final int bodyStart = frame.getBodyRowOffset();
for (int i = 0; i < tiles.size(); i++) {
final Tile tile = tiles.get(i);
final int y = bodyStart + layout.rowOf[i];
if (tile instanceof ElseTile)
new AElseSeparator(width, ((ElseTile) tile).asciiLabel()).asciiDraw(plan.move(left, y));
else
tile.asciiDraw(plan.move(0, y));
}
}
// The group's title as a single flat line: for a plain "group"/partition
// it is just the comment, otherwise "title comment" — mirroring how the
// pixel `display` field is built in the constructor. Multi-line titles are
// flattened for now, the same simplification message labels used before
// Display became an AsciiBlock; good enough for the single-word
// partition/group titles the current tests use.
protected String asciiTitle() {
final StringBuilder sb = new StringBuilder();
for (CharSequence cs : display) {
if (sb.length() > 0)
sb.append(' ');
sb.append(cs);
}
return sb.toString();
}
public Real getMinX() {
return min.addFixed(-EXTERNAL_MARGINX1 - getNotesWidth(getStringBounder(), NotePosition.LEFT));
}
public Real getMaxX() {
return max.addFixed(EXTERNAL_MARGINX2 + getNotesWidth(getStringBounder(), NotePosition.RIGHT));
}
// Notes attached to the group ("note left"/"note right" just after the end
// keyword) are drawn like in Puma: at the top corner of the group frame, on
// the requested side (see GroupingGraphicalElementHeader)
private void drawNotes(UGraphic ug) {
final StringBounder stringBounder = ug.getStringBounder();
double xRight = max.getCurrentValue();
for (Component note : getNoteComponents(NotePosition.RIGHT)) {
final XDimension2D dimNote = note.getPreferredDimension(stringBounder);
note.drawU(ug.apply(UTranslate.dx(xRight)), Area.create(dimNote.getWidth(), dimNote.getHeight()),
(Context2D) ug);
xRight += dimNote.getWidth();
}
double xLeft = min.getCurrentValue();
for (Component note : getNoteComponents(NotePosition.LEFT)) {
final XDimension2D dimNote = note.getPreferredDimension(stringBounder);
note.drawU(ug.apply(UTranslate.dx(xLeft - dimNote.getWidth())),
Area.create(dimNote.getWidth(), dimNote.getHeight()), (Context2D) ug);
xLeft -= dimNote.getWidth();
}
}
private double getNotesWidth(StringBounder stringBounder, NotePosition position) {
double result = 0;
for (Component note : getNoteComponents(position))
result += note.getPreferredDimension(stringBounder).getWidth();
return result;
}
private List<Component> getNoteComponents(NotePosition position) {
if (end == null)
return Collections.emptyList();
final List<Component> result = new ArrayList<>();
for (Note noteOnMessage : end.getNoteOnMessages()) {
// A "note left" is drawn on the left side of the frame, everything
// else (right, or a position that defaulted to right) on the right
final boolean isLeft = noteOnMessage.getPosition() == NotePosition.LEFT;
if (isLeft != (position == NotePosition.LEFT))
continue;
final ISkinParam sk = noteOnMessage.getSkinParamBackcolored(skinParam);
result.add(skin.createComponentNote(noteOnMessage.getUsedStyles(),
noteOnMessage.getNoteStyle().getNoteComponentType(), sk, noteOnMessage.getDisplay(),
noteOnMessage.getColors()));
}
return result;
}
// Walks the tile tree to run each tile's notifyPositioned() -- which no longer
// POSITIONS anything (every tile reads its own solved gauge) but still drives
// the livebox steps, goCreate/goDestroy and the local/full tile lists used
// for LinkAnchor lookups.
public static void fillPositionelTiles(StringBounder stringBounder, List<Tile> tiles,
final List<CommonTile> local, List<CommonTile> full) {
for (Tile tile : tiles) {
tile.onGaugeResolved();
local.add((CommonTile) tile);
full.add((CommonTile) tile);
if (tile instanceof GroupingTile)
fillPositionalSubGroupTiles(stringBounder, full, (GroupingTile) tile);
}
}
private static void fillPositionalSubGroupTiles(StringBounder stringBounder, List<CommonTile> full,
GroupingTile groupingTile) {
final ArrayList<CommonTile> local2 = new ArrayList<>();
fillPositionelTiles(stringBounder, groupingTile.tiles, local2, full);
}
// Sum of the tiles' heights, counting each run of parallel (&) tiles ONCE:
// the members of a & cluster share a contact line, so they overlap, and the
// cluster's real vertical extent is tallest-before-contact +
// tallest-after-contact -- NOT the sum of their heights (which would
// double-count the overlap and make a group with a & message too tall).
//
// This is a throwaway, height-only pass: Y POSITIONING goes through the gauge
// chain, built at construction time, and `tiles` stays flat for drawing. It
// reproduces exactly what the former mergeParallelCore /
// moveRecentParallelTilesToPending produced -- the SAME grouping of tiles
// into clusters -- but sums the cluster extents directly instead of building
// throwaway TileParallel objects to hold them.
//
// Grouping rule (a faithful restatement of the former mergeParallelCore /
// moveRecentParallelTilesToPending): a `pending` list collects the tiles that
// could still be pulled into a cluster. A parallel tile joins (or opens) the
// cluster, pulling the WHOLE pending list in with it -- this is how the run's
// true first member, sitting just before the first `&`, plus any
// LifeEventTiles between them, end up in the cluster. A LifeEventTile is
// NEVER a separator: it too is merely held in pending (it can be pulled into
// a cluster, but can never start one on its own). Only a non-parallel,
// non-LifeEvent tile flushes the pending run and starts a fresh one with
// itself.
//
// Crucially, a tile's height is added to the total ONLY when the run it
// belongs to is flushed -- never eagerly -- so a tile already "placed" can
// still be retroactively pulled into a following cluster without any height
// having to be subtracted back (exactly what the former code achieved by
// moving tiles out of `result` and into the TileParallel).
private static double computeBodyHeight(List<Tile> tiles) {
double total = 0;
// Tiles held back, still eligible to be pulled into a cluster by a
// following `&`: a lone seed possibly trailed by life events, or (once a
// `&` has been seen) the growing cluster itself.
final List<Tile> pending = new ArrayList<>();
boolean clusterOpen = false;
for (Tile tile : tiles) {
if (isParallel(tile)) {
// Joins/opens the cluster, pulling in whatever is pending.
clusterOpen = true;
pending.add(tile);
} else if (tile instanceof LifeEventTile) {
// Never a separator: held in pending so a later `&` can still
// pull it (and everything before it in the run) into a cluster.
pending.add(tile);
} else {
// A real separator: flush the pending run, then start a fresh
// one seeded with this tile.
total += flushPending(pending, clusterOpen);
clusterOpen = false;
pending.add(tile);
}
}
total += flushPending(pending, clusterOpen);
return total;
}
// Consumes `pending` (clearing it) and returns its total height. When the run
// held a `&` cluster, that is the cluster's real extent -- tallest-before-
// contact + tallest-after-contact over ALL its members (they overlap on the
// shared contact line, so their heights are NOT summed). Otherwise it is the
// plain sum of the members' preferred heights (a lone seed, possibly trailed
// by life events that were never pulled into a cluster).
private static double flushPending(List<Tile> pending, boolean wasCluster) {
if (pending.isEmpty())
return 0;
double result = 0;
if (wasCluster) {
double contact = 0;
double after = 0;
for (Tile tile : pending) {
contact = Math.max(contact, tile.getContactPointRelative());
after = Math.max(after, tile.getZZZ());
}
result = contact + after;
} else {
for (Tile tile : pending)
result += tile.getPreferredHeight();
}
pending.clear();
return result;
}
public static boolean isParallel(Tile tile) {
return tile.getEvent().isParallel();
}
// ===================== ASCII height-only clustering =====================
//
// ASCII counterpart of computeBodyHeight()/flushPending() above -- same
// clustering rule (pending list, retroactive capture of a run's first
// member, LifeEventTiles held in pending rather than acting as separators),
// reused verbatim, because a `&` run is still a run whether measured in
// pixels or rows.
//
// ONE deliberate difference from the pixel version: a cluster's height here
// is the MAX of its members' own asciiDimension() heights, not
// max(contactPointRelative) + max(zzz). There is no ASCII Y gauge yet (see
// ASCIIVERSE.md §32.7-§32.11): no ASCII tile publishes a "contact row"
// distinct from its own height, so "tallest-before/after-contact" has no
// ASCII equivalent to read. All this can honestly claim is: the members of
// a `&` run SHARE a row span (so the group/document reserves only the
// tallest member's height instead of summing them all -- the wasted-space
// half of the bug), each drawn starting at the SAME row. It does NOT align
// their own arrows/labels on a shared internal row the way pixel's
// contact-line sharing does -- that is exactly the still-open Y-gauge work,
// not this fix. See ASCIIVERSE.md §32.7 (bug 1) for the full picture.
//
// Used by both asciiBodyHeight()/asciiDraw() here (a `&` run inside a
// group) and PlayingSpaceWithParticipants.asciiDraw() (a `&` run at the
// top level) -- one shared implementation, not two copies, per the lesson
// already recorded in YGAUGE.md: the day a second caller needs this exact
// computation, share it rather than duplicate it.
static final class AsciiLayout {
// rowOf[i]: the row (relative to this block's own top, 0-based) at which
// tiles.get(i) should be drawn. Every member of a `&` cluster gets the
// SAME row.
final int[] rowOf;
final int totalHeight;
AsciiLayout(int[] rowOf, int totalHeight) {
this.rowOf = rowOf;
this.totalHeight = totalHeight;
}
}
static AsciiLayout computeAsciiLayout(List<Tile> tiles) {
final int[] rowOf = new int[tiles.size()];
int y = 0;
// Indices into `tiles`, mirroring computeBodyHeight()'s `pending` list of
// Tile references -- kept as indices here since we must also WRITE each
// member's row, not just read its height.
final List<Integer> pending = new ArrayList<>();
boolean clusterOpen = false;
for (int i = 0; i < tiles.size(); i++) {
final Tile tile = tiles.get(i);
if (isParallel(tile)) {
clusterOpen = true;
pending.add(i);
} else if (tile instanceof LifeEventTile) {
pending.add(i);
} else {
y += flushAsciiPending(tiles, pending, clusterOpen, rowOf, y);
clusterOpen = false;
pending.add(i);
}
}
y += flushAsciiPending(tiles, pending, clusterOpen, rowOf, y);
return new AsciiLayout(rowOf, y);
}
// Consumes `pending` (clearing it), writes each member's row into `rowOf`,
// and returns the run's total row count. A `&` cluster: every member
// starts at row `y` (the SAME row -- see AsciiLayout's own comment on why
// this is a wasted-space fix, not a contact-alignment one), height = the
// max of their own asciiDimension() heights. A plain sequential run:
// members stack one after another starting at `y`, height = their sum.
private static int flushAsciiPending(List<Tile> tiles, List<Integer> pending, boolean wasCluster, int[] rowOf,
int y) {
if (pending.isEmpty())
return 0;
int height;
if (wasCluster) {
int max = 0;
for (int idx : pending)
max = Math.max(max, tiles.get(idx).asciiDimension().getHeight());
for (int idx : pending)
rowOf[idx] = y;
height = max;
} else {
int localY = y;
for (int idx : pending) {
rowOf[idx] = localY;
localY += tiles.get(idx).asciiDimension().getHeight();
}
height = localY - y;
}
pending.clear();
return height;
}
void addNewpageTiles(List<NewpageTile> newpages) {
for (Tile tile : tiles) {
if (tile instanceof GroupingTile)
((GroupingTile) tile).addNewpageTiles(newpages);
if (tile instanceof NewpageTile)
newpages.add((NewpageTile) tile);
}
}
public GroupingStart getGroupingStart() {
return start;
}
// Package-private child-list accessor for PlayingSpace's parallel-sibling
// disjointness pass (see PlayingSpace.findAnchorLivingSpace()): it needs to
// walk into a group to find ANY leaf message/participant to safely compare
// declaration order against, without ever reading this tile's own cached
// min/max (see the ASCII_FRAME_MARGIN / MARGINX comments on why that's
// unsafe pre-compile).
List<Tile> getChildTilesForAnchor() {
return tiles;
}
public TileArguments getTileArguments() {
return tileArguments;
}
public ISkinParam getSkinParam() {
return skinParam;
}
}