AGroupFrame.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.asciiverse;

// The frame box that encloses a group (frame, alt, opt, loop, partition, ...)
// as an AsciiBlock: the ASCII counterpart of the pixel GROUPING_HEADER_TEOZ
// Component that GroupingTile.drawU() obtains from the skin and delegates to
// (see the design notes below). Third cut of this class: ATXT and UTXT are
// now genuinely different shapes (not a character-substitution of the same
// layout, the way ANote's two variants are) — ATXT keeps the pentagon-style
// cut-corner tab; UTXT draws a small single-line rectangular
// tab sharing the frame's own top/left double-line border, with its right
// edge continuing as a single-line divider for the frame's full height. Split
// into asciiDrawAscii()/asciiDrawUnicode(), the same dispatch shape ANote
// already uses, rather than one method with isUnicode() branches
// threaded through shared geometry — the two shapes don't actually share
// enough geometry to make that worthwhile.
//
// This is a *container* whose size is
// dictated from the outside (the span of the group's children and its
// stacked body height) — so it takes an explicit ADimension2D rather than
// computing one, the same distinction as ANote's self-sized text vs. a
// pixel Component drawn into an Area handed to it.
public final class AGroupFrame implements AsciiBlock {

	private final ADimension2D dimension;
	private final String title;
	// True for a plain "frame"/"alt"/"loop"/... group: the pentagon-style
	// cut-corner tab. False for a partition: a partition's pixel rendering (PartitionTile.getComponent())
	// never draws a tab at all — it stamps the title centered directly on the
	// frame's own top border, the plain box shape this class's very first cut used before the tab was introduced. Rather than duplicating
	// AGroupFrame (border, sides, corners are identical either way) this one
	// class now supports both shapes, switched on this flag, exactly like
	// asciiDraw() already dispatches on isUnicode() for ATXT vs UTXT.
	private final boolean useTab;

	public AGroupFrame(ADimension2D dimension, String title) {
		this(dimension, title, true);
	}

	public AGroupFrame(ADimension2D dimension, String title, boolean useTab) {
		this.dimension = dimension;
		this.title = title == null ? "" : title;
		this.useTab = useTab;
	}

	@Override
	public ADimension2D asciiDimension() {
		return dimension;
	}

	// How many rows, counted from this frame's own top, are consumed by the
	// header — the top border alone (1) when there's no title or it wouldn't
	// fit, or the top border plus the title tab's two rows (3: text/tab row,
	// then the tab's own closing row) otherwise. Same row count in both ATXT
	// and UTXT — only the characters differ, not the geometry — so this stays
	// a single, format-agnostic method. GroupingTile needs it to know where
	// its own child-stacking loop should start; rather than duplicating the
	// "does the tab fit" guard in GroupingTile, it builds a throwaway
	// AGroupFrame and reads this back — the same "one place owns the size
	// rule, callers read it back" discipline as ANote.
	public int getBodyRowOffset() {
		return hasTab() ? 3 : 1;
	}

	private boolean hasTab() {
		return useTab && title.isEmpty() == false && dimension.getWidth() > tabWidth() + 2;
	}

	// The plain-frame counterpart of hasTab(): whether the title is stamped
	// centered on the top border itself (useTab == false, a partition) rather
	// than in a tab. Same "skip if it doesn't fit" policy as hasTab() — a
	// title longer than the frame simply doesn't get drawn, rather than
	// truncated or overflowing past the corners.
	private boolean hasTitleOnBorder() {
		return useTab == false && title.isEmpty() == false
				&& dimension.getWidth() >= title.length() + 2 + 2;
	}

	// Width of the tab's own text block: " " + title + " ", one padding
	// column each side, the same tab-text convention the first cut of this
	// class used for its title stamp. Both asciiDrawAscii() and
	// asciiDrawUnicode() start their own closing character(s) right after
	// this block, at column tabWidth() + 1 (1-indexed from the tab's own
	// left, i.e. column 1 relative to this frame).
	private int tabWidth() {
		return title.length() + 2;
	}

	@Override
	public void asciiDraw(InfinitePlan plan) {
		if (plan.isUnicode())
			asciiDrawUnicode(plan);
		else
			asciiDrawAscii(plan);
	}

	// ATXT: dashed top border, '!' sides, tildes across the bottom, and a
	// pentagon-style cut-corner tab — see those sections for the
	// full rationale of each choice; unchanged by this section's UTXT work.
	private void asciiDrawAscii(InfinitePlan plan) {
		final int width = dimension.getWidth();
		final int height = dimension.getHeight();

		// Top border: a plain dashed line with corners — an ordinary box edge,
		// so its corners stay Unicode-aware (getTopLeftChar()/getTopRightChar(),
		// opened up for this) and its dash run reuses drawHLine(), which
		// already picks '-' vs '─' internally — moot here since this
		// method is only ever reached in ASCII mode, but kept for symmetry with
		// asciiDrawUnicode() reading the same plan. Not drawn via drawBox():
		// that would also draw a matching bottom border and '|' sides, both
		// wrong here (see below).
		if (width >= 1) {
			plan.move(0, 0).drawChar(plan.getTopLeftChar());
			if (width > 2)
				plan.drawHLine(1, width - 2, 0);
			if (width > 1)
				plan.move(width - 1, 0).drawChar(plan.getTopRightChar());
		}

		// Sides: '!', deliberately not '|'. A frame border must stay visually
		// distinct from a participant's lifeline running through it, which a
		// shared '|' cannot do — this is exactly the ambiguity the first cut
		// of this class had (its drawBox()-based '|' sides collided with
		// crossing lifelines, e.g. the hello4 reference output reads
		// "-|p1", the frame's own corner and a lifeline character
		// indistinguishable from one another).
		for (int y = 1; y <= height - 2; y++) {
			plan.move(0, y).drawChar('!');
			if (width > 1)
				plan.move(width - 1, y).drawChar('!');
		}

		// Bottom border: tildes across the full width for a tab-style frame, no
		// distinct corner characters — a deliberately different shape from the
		// top border, so the frame's footer reads as visually distinct from its
		// header at a glance. A confirmed stylistic choice (per the mockup), not
		// a plain box edge with a character swapped in. UTXT does not repeat this choice: its bottom border is a plain double line, matching
		// its top. A plain (useTab == false) frame — a partition — has no such
		// header/footer asymmetry to signal in the first place (its title sits
		// on the top border itself, not in a tab), so its bottom mirrors its top
		// exactly: same corners, same dash run, via drawBox()'s own convention.
		if (height >= 2) {
			if (useTab) {
				for (int x = 0; x < width; x++)
					plan.move(x, height - 1).drawChar('~');
			} else {
				plan.move(0, height - 1).drawChar(plan.getBottomLeftChar());
				if (width > 2)
					plan.drawHLine(1, width - 2, height - 1);
				if (width > 1)
					plan.move(width - 1, height - 1).drawChar(plan.getBottomRightChar());
			}
		}

		// The title's small pentagon-style tab, attached to the top-left
		// inside the frame — one column/row padding from the border, the same
		// convention ANote's text uses — text row, then a diagonal fold
		// row (underscores + a trailing '/'). This is the ASCII transposition
		// of the small cut-corner tab the pixel GROUPING_HEADER_TEOZ component
		// actually draws, rather than the plain "stamp the title on the top
		// border" treatment the first cut of this class used. Skipped
		// (falls back to the top border alone, getBodyRowOffset() == 1) when
		// there's no title or it wouldn't fit — the same "skip if it doesn't
		// fit" policy the title stamp it replaces already had.
		//
		// The text row ends in " /" rather than a plain padding space: the
		// tab needs its own right edge closed off, the same way its left edge is
		// implicitly closed by the frame's own '!' at column 0. That trailing '/'
		// lands one column to the right of, and one row above, the fold row's own
		// '/' directly beneath it — so the two slashes form a genuine 45-degree
		// diagonal cut running across both rows, rather than a straight edge on
		// one row with a separate cut below it: the standard UML frame
		// cut-corner pentagon look, just traced over two character rows instead
		// of one continuous line.
		if (hasTab()) {
			final int tabWidth = tabWidth();
			plan.move(1, 1).drawString(" " + title + " /");
			for (int x = 0; x < tabWidth - 1; x++)
				plan.move(1 + x, 2).drawChar('_');
			plan.move(1 + tabWidth - 1, 2).drawChar('/');
		}

		drawTitleOnBorderIfNeeded(plan, width);
	}

	// A partition's title (useTab == false): stamped centered directly on the
	// top border row, overwriting the dashes there — the plain "stamp the
	// title on the top border" treatment this class's first cut used
	// before the tab was introduced for other group kinds, and exactly
	// what PartitionTile.getComponent() does in pixel: draw the title, then
	// the rectangle border, centered over the same top edge. Shared verbatim
	// between ATXT and UTXT (only the border characters around it differ,
	// already drawn by the caller before this runs) so it lives once, outside
	// either asciiDrawAscii()/asciiDrawUnicode().
	private void drawTitleOnBorderIfNeeded(InfinitePlan plan, int width) {
		if (hasTitleOnBorder() == false)
			return;

		final String labeled = " " + title + " ";
		final int start = (width - labeled.length()) / 2;
		plan.move(start, 0).drawString(labeled);
	}

	// UTXT: a double-line frame (╔═╗/║/╚═╝ — the same double-line convention
	// ANote already uses for its own Unicode box, keeping frames and
	// notes visually matched and both distinct from participants' single-line
	// boxes and lifelines), with a small single-line tab in the top-left that
	// shares the frame's own top and left border rather than drawing its own
	// (mirroring the legacy asciiart.ComponentTextGroupingHeader's UTXT
	// branch — copied for the idea and the junction characters, not the code,
	// per the standing rule). One deliberate departure from
	// that legacy rendering: here the tab's right edge continues as a plain
	// single-line divider for the frame's *entire* height, down to a junction
	// with the bottom border — confirmed with Arnaud, who wanted the full
	// height rather than the legacy rendering's divider stopping at the tab's
	// own three rows.
	private void asciiDrawUnicode(InfinitePlan plan) {
		final int width = dimension.getWidth();
		final int height = dimension.getHeight();
		final boolean tab = hasTab();
		// One column past the tab's own text block (tabWidth()) — the
		// column every one of the tab's own characters below is drawn at:
		// the top-border junction, the text row's closing '│', the tab's own
		// bottom-right corner, and the full-height divider underneath it all.
		final int dividerColumn = 1 + tabWidth();

		// Top border: double line, with a junction ('╤', DOWN SINGLE AND
		// HORIZONTAL DOUBLE) where the tab's divider begins, instead of a
		// plain '═' at that one column.
		if (width >= 1) {
			plan.move(0, 0).drawChar('╔');
			if (width > 2)
				for (int x = 1; x <= width - 2; x++)
					plan.move(x, 0).drawChar(x == dividerColumn && tab ? '╤' : '═');
			if (width > 1)
				plan.move(width - 1, 0).drawChar('╗');
		}

		// Sides: double line, for every row between the top and bottom
		// borders — the frame's own outer edge, distinct from the tab's
		// single-line divider drawn separately below.
		for (int y = 1; y <= height - 2; y++) {
			plan.move(0, y).drawChar('║');
			if (width > 1)
				plan.move(width - 1, y).drawChar('║');
		}

		// Bottom border: double line, plain — unlike ATXT (asciiDrawAscii()),
		// UTXT does not switch to a different character for the footer; a
		// junction ('╧', UP SINGLE AND HORIZONTAL DOUBLE) closes off the
		// divider where it meets this border, the mirror image of the '╤'
		// junction at the top. (A plain, useTab == false frame has no divider at
		// all, so `tab` is already false here and every column is a plain '═'.)
		if (height >= 2) {
			plan.move(0, height - 1).drawChar('╚');
			for (int x = 1; x <= width - 2; x++)
				plan.move(x, height - 1).drawChar(x == dividerColumn && tab ? '╧' : '═');
			if (width > 1)
				plan.move(width - 1, height - 1).drawChar('╝');
		}

		// The tab itself: no border of its own on the top or left — it shares
		// the frame's own '╔'/'═' top border and '║' left side,
		// drawn above. It only owns its right edge (a single-line '\u2502' on
		// the text row, continuing as the full-height divider below) and its
		// bottom edge (a single-line '─' run, with a '╟' junction on
		// the left where it meets the frame's own double-line side, and a
		// '┘' corner on the right where it meets the divider) — the same
		// "text row, then a closing row" shape as the ASCII tab, just with box
		// characters instead of underscores and a diagonal.
		if (tab) {
			final int tabWidth = tabWidth();
			plan.move(1, 1).drawString(" " + title + " ");
			plan.move(dividerColumn, 1).drawChar('│');

			plan.move(0, 2).drawChar('╟');
			for (int x = 1; x < tabWidth + 1; x++)
				plan.move(x, 2).drawChar('─');
			plan.move(dividerColumn, 2).drawChar('┘');

			// The divider: a plain single-line '│', continuing from the
			// tab's own closing row down through the rest of the body, to
			// (not including) the bottom border — which closes it off with
			// its own '╧' junction, drawn above.
			for (int y = 3; y <= height - 2; y++)
				plan.move(dividerColumn, y).drawChar('│');
		}

		drawTitleOnBorderIfNeeded(plan, width);
	}

}