This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch timeline-layout in repository https://gitbox.apache.org/repos/asf/camel.git
commit 77116e8d9f958c59d8d1a53a5c07ec95bac4db69 Author: Claus Ibsen <[email protected]> AuthorDate: Mon Nov 9 09:58:08 2026 +0100 chore: camel-jbang - Timeline: room around the panels, and taller charts and strips on a tall screen Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../ROOT/pages/camel-jbang-tui-observe.adoc | 4 +- .../dsl/jbang/core/commands/tui/TimelineTab.java | 228 +++++++++++++++------ .../src/main/resources/tui/help/timeline.md | 2 + .../core/commands/tui/TimelineTabRenderTest.java | 73 +++++++ 4 files changed, 238 insertions(+), 69 deletions(-) diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-observe.adoc b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-observe.adoc index 0e308f1b0fdf..d6599f431a84 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-observe.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-observe.adoc @@ -53,8 +53,8 @@ camel-chaos, the faults of each chaos rule are a layer of their own, marked ๐ฒ apart. When no exchange failed, the WARN and ERROR lines of the log are the cause instead (`log ERROR`, `log WARN`), read from the log file of the integration: something that went wrong outside an exchange, such as a consumer that cannot connect. When nothing went wrong the band is one line. -* *Routes* has a line per route. The height is the exchanges done that second, and the colour the worst that happened to -them: completed, recovered by redelivery, handled by the error handler (such as the dead letter channel), or failed. A +* *Routes* has a line per route (on a tall screen two rows high, with room around the panels and taller charts). The +height is the exchanges done that second, and the colour the worst that happened to them: completed, recovered by redelivery, handled by the error handler (such as the dead letter channel), or failed. A second a chaos rule had the route down is shaded, and a mark says what happened to the route then: `โถ` started, `โ ` stopped, `โป` reloaded, `โ` removed. A `โ` is a second a circuit breaker of the route was open (the Circuit Breaker EIP with camel-resilience4j or camel-microprofile-fault-tolerance, or the throttling exception route policy), and the route diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TimelineTab.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TimelineTab.java index 6875f44e8452..3de12c0904b1 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TimelineTab.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TimelineTab.java @@ -25,6 +25,7 @@ import java.util.function.LongSupplier; import dev.tamboui.layout.Constraint; import dev.tamboui.layout.Layout; +import dev.tamboui.layout.Padding; import dev.tamboui.layout.Rect; import dev.tamboui.style.Style; import dev.tamboui.terminal.Frame; @@ -62,6 +63,12 @@ class TimelineTab extends AbstractTableTab { private static final int DETAIL_LINES = DETAIL_ROWS + 5; private static final int SUFFIX = 30; private static final int MAX_LABEL = 18; + // a tall screen (this many rows to spare) has room around the panels, and its charts and strips grow + static final int ROOMY_ROWS = 12; + // the rows the room around takes: the padding of both panels, a blank line in each, and the line between them + private static final int ROOMY_EXTRA = 9; + private static final int MAX_CHART_ROWS = 6; + private static final int MAX_STRIP_ROWS = 2; private final LongSupplier clock; // the seconds a column has, and the characters a column is wide, as the last render worked them out @@ -72,7 +79,11 @@ class TimelineTab extends AbstractTableTab { private long pausedAt = -1; // the column of the cursor while paused private int cursor = -1; - private int routeLinesY = -1; + // where the strips of the routes are, for the clicks: the first line, the lines from one route to the next, the + // lines of a strip + private int routeTop = -1; + private int routePitch = 1; + private int routeStrip = 1; private final TimelineExchanges drill = new TimelineExchanges(); TimelineTab(MonitorContext ctx) { @@ -285,10 +296,50 @@ class TimelineTab extends AbstractTableTab { @Override public boolean handleMouseEvent(MouseEvent me, Rect area) { - if (routeLinesY < 0 || lastTableArea == null) { + if (routeTop < 0 || !me.isClick() || lastTableArea == null || !lastTableArea.contains(me.x(), me.y())) { return false; } - return handleTableClick(me, lastTableArea, tableState, getRowCount()); + // a click on a strip of a route selects it, not on the blank line between two routes + int dy = me.y() - routeTop; + int i = dy / routePitch; + if (dy >= 0 && dy % routePitch < routeStrip && i < getRowCount()) { + tableState.select(i); + return true; + } + return false; + } + + /** + * How the screen is laid out: compact, or on a tall screen with room around the panels, the charts this many rows + * high, the strips of the routes this many rows high and a blank line between them. + */ + record Room(boolean roomy, int causeRows, int detailRows, int strip, boolean spaced) { + static final Room COMPACT = new Room(false, CAUSE_ROWS, DETAIL_ROWS, 1, false); + } + + /** + * The room the screen has: the rows to spare go first to the room around the panels, then a blank line between the + * routes, then taller strips, and the rest to the charts (a line kept free at the bottom). + */ + static Room room(int height, int routes, boolean anyCause) { + int compact = (anyCause ? CAUSE_ROWS + 1 : 1) + routes + 4 + DETAIL_LINES + 2; + int spare = height - compact; + if (spare < ROOMY_ROWS) { + return Room.COMPACT; + } + spare -= ROOMY_EXTRA + 1; + boolean spaced = routes > 1 && spare >= routes - 1; + if (spaced) { + spare -= routes - 1; + } + int strip = 1; + if (routes > 0) { + strip = Math.min(MAX_STRIP_ROWS, 1 + spare / routes); + spare -= (strip - 1) * routes; + } + int cause = anyCause ? Math.min(MAX_CHART_ROWS - CAUSE_ROWS, spare / 2) : 0; + int detail = Math.min(MAX_CHART_ROWS - DETAIL_ROWS, spare - cause); + return new Room(true, CAUSE_ROWS + cause, DETAIL_ROWS + detail, strip, spaced); } int seconds() { @@ -323,20 +374,27 @@ class TimelineTab extends AbstractTableTab { List<Timeline.RouteSeries> routes = routes(info); List<Timeline.CauseSeries> causes = ctx.timeline.causes(info.pid, endSecond(), seconds()); boolean anyCause = causes.stream().anyMatch(c -> sum(c.values()) > 0); - int causeHeight = anyCause ? CAUSE_ROWS + 1 : 1; - // the cause, the legend of the routes, a line per route and the time axis, in a border - int detailHeight = DETAIL_LINES + 2; - int topHeight = Math.max(6, Math.min(area.height() - detailHeight, causeHeight + routes.size() + 4)); + Room room = room(area.height(), routes.size(), anyCause); + // on a tall screen: padding in the panels, a blank line after the cause, after the legend and in the detail, + // and a line between the panels + int gap = room.roomy() ? 1 : 0; + int causeHeight = anyCause ? room.causeRows() + 1 : 1; + int routeHeight = routes.size() * room.strip() + (room.spaced() ? Math.max(0, routes.size() - 1) : 0); + // the cause, the legend of the routes, a strip per route and the time axis, in a border + int detailHeight = DETAIL_LINES + 2 + room.detailRows() - DETAIL_ROWS + 4 * gap; + int topHeight = Math.max(6, + Math.min(area.height() - detailHeight - gap, causeHeight + Math.max(1, routeHeight) + 4 + 4 * gap)); List<Rect> rows = Layout.vertical() - .constraints(Constraint.length(topHeight), Constraint.length(detailHeight), Constraint.fill()) + .constraints(Constraint.length(topHeight), Constraint.length(gap), Constraint.length(detailHeight), + Constraint.fill()) .split(area); - int inner = Math.max(20, area.width() - 2); + int inner = Math.max(20, area.width() - 2 - 2 * gap); int label = Math.min(MAX_LABEL, Math.max(11, routes.stream().mapToInt(r -> r.route().length()).max().orElse(0))) + 4; // a second is 1 to 3 characters wide as the screen allows, or a character is several seconds - int room = Math.max(10, inner - label - SUFFIX - 1); - int per = room >= seconds() ? 1 : (int) Math.ceil((double) seconds() / room); - width = per == 1 ? Math.max(1, Math.min(3, room / seconds())) : 1; + int space = Math.max(10, inner - label - SUFFIX - 1); + int per = space >= seconds() ? 1 : (int) Math.ceil((double) seconds() / space); + width = per == 1 ? Math.max(1, Math.min(3, space / seconds())) : 1; this.per = per; int columns = (seconds() + per - 1) / per * width; if (cursor >= 0) { @@ -345,42 +403,60 @@ class TimelineTab extends AbstractTableTab { } List<Line> lines = new ArrayList<>(); - renderCauses(lines, causes, anyCause, info, columns, per, label); - boolean anyOpen = routes.stream().anyMatch(r -> r.total(r.open()) > 0); - lines.add(Line.from(Span.raw(pad("", label)), Span.styled("Routes ", Theme.label()), - Span.styled("โ", Theme.success()), Span.raw(" completed "), - Span.styled("โ", Theme.warning()), Span.raw(" recovered "), - Span.styled("โ", handledStyle()), Span.raw(" handled "), - Span.styled("โ", Theme.error()), Span.raw(" failed "), - Span.styled("โ", Theme.error()), Span.raw(" down"), - Span.raw(anyOpen ? " " : ""), Span.styled(anyOpen ? "โ" : "", Theme.notice()), - Span.raw(anyOpen ? " circuit breaker open" : ""), - Span.raw(routes.stream().anyMatch(r -> Arrays.stream(r.events()).anyMatch(e -> e != null)) - ? " โถ started โ stopped โป reloaded" : ""))); + renderCauses(lines, causes, anyCause, info, columns, room.causeRows(), label); + blank(lines, room.roomy()); + lines.add(legend(routes, label)); + blank(lines, room.roomy()); Integer sel = tableState.selected(); - routeLinesY = lines.size(); + int routeLinesY = lines.size(); for (int i = 0; i < routes.size(); i++) { - lines.add(routeLine(routes.get(i), sel != null && sel == i, columns, per, label)); + blank(lines, room.spaced() && i > 0); + lines.addAll(routeLines(routes.get(i), sel != null && sel == i, columns, per, label, room.strip())); } if (routes.isEmpty()) { lines.add(Line.from(Span.styled(pad("", label) + "no routes yet", Theme.muted()))); } lines.add(axis(columns, per, label)); Rect top = rows.get(0); - // the rows of the routes, as a table with a header line of two would have them, for the clicks - lastTableArea = new Rect(top.x(), top.y() + 1 + routeLinesY - 2, top.width(), routes.size() + 2); - frame.renderWidget(Paragraph.builder().text(Text.from(lines)).block(block(title(info))).build(), top); + // the lines of the routes, for the clicks + routeTop = top.y() + 1 + gap + routeLinesY; + routePitch = room.strip() + (room.spaced() ? 1 : 0); + routeStrip = room.strip(); + lastTableArea = new Rect(top.x(), routeTop, top.width(), Math.max(1, routeHeight)); + Padding padding = room.roomy() ? Padding.uniform(1) : Padding.NONE; + frame.renderWidget(Paragraph.builder().text(Text.from(lines)).block(block(title(info), padding)).build(), top); if (drill.isOpen()) { List<Timeline.Kept> kept = keptAtCursor(); - frame.renderWidget(Paragraph.builder().text(Text.from(drill.lines(kept, DETAIL_LINES))) - .block(block(drill.title(kept, ctx.timeline.done(info.pid, drill.from(), drill.to())))).build(), - rows.get(1)); + frame.renderWidget(Paragraph.builder().text(Text.from(drill.lines(kept, detailHeight - 2 - 2 * gap))) + .block(block(drill.title(kept, ctx.timeline.done(info.pid, drill.from(), drill.to())), padding)) + .build(), + rows.get(2)); return; } Timeline.RouteSeries selected = sel != null && sel >= 0 && sel < routes.size() ? routes.get(sel) : null; - renderDetail(frame, rows.get(1), selected, columns, per, label, info); + renderDetail(frame, rows.get(2), selected, columns, label, info, room); + } + + private static void blank(List<Line> lines, boolean when) { + if (when) { + lines.add(Line.from(Span.raw(""))); + } + } + /** The legend of the strips of the routes. */ + private static Line legend(List<Timeline.RouteSeries> routes, int label) { + boolean anyOpen = routes.stream().anyMatch(r -> r.total(r.open()) > 0); + return Line.from(Span.raw(pad("", label)), Span.styled("Routes ", Theme.label()), + Span.styled("โ", Theme.success()), Span.raw(" completed "), + Span.styled("โ", Theme.warning()), Span.raw(" recovered "), + Span.styled("โ", handledStyle()), Span.raw(" handled "), + Span.styled("โ", Theme.error()), Span.raw(" failed "), + Span.styled("โ", Theme.error()), Span.raw(" down"), + Span.raw(anyOpen ? " " : ""), Span.styled(anyOpen ? "โ" : "", Theme.notice()), + Span.raw(anyOpen ? " circuit breaker open" : ""), + Span.raw(routes.stream().anyMatch(r -> Arrays.stream(r.events()).anyMatch(e -> e != null)) + ? " โถ started โ stopped โป reloaded" : "")); } private String title(IntegrationInfo info) { @@ -406,7 +482,7 @@ class TimelineTab extends AbstractTableTab { private void renderCauses( List<Line> lines, List<Timeline.CauseSeries> causes, boolean anyCause, IntegrationInfo info, int columns, - int per, int label) { + int rows, int label) { List<Span> legend = new ArrayList<>(); legend.add(Span.raw(pad("", label))); legend.add(Span.styled("Errors/s ", Theme.label())); @@ -433,7 +509,7 @@ class TimelineTab extends AbstractTableTab { return; } lines.add(Line.from(legend)); - lines.addAll(stacked(values, styles, CAUSE_ROWS, columns, label)); + lines.addAll(stacked(values, styles, rows, columns, label)); } private static Style causeStyle(int i) { @@ -445,12 +521,12 @@ class TimelineTab extends AbstractTableTab { // ---- the routes ---- - private Line routeLine(Timeline.RouteSeries r, boolean selected, int columns, int per, int label) { - List<Span> spans = new ArrayList<>(); + /** + * The strip of a route, {@code strip} rows high (the blocks stacked, so a strip of 2 rows has 16 levels), its name + * and its summary on the bottom row, where the blocks stand. + */ + private List<Line> routeLines(Timeline.RouteSeries r, boolean selected, int columns, int per, int label, int strip) { String name = r.route().length() > label - 3 ? r.route().substring(0, label - 4) + "โฆ" : r.route(); - spans.add(selected - ? Span.styled(pad(">> " + name, label), Theme.selectionBg()) - : Span.raw(pad(" " + name, label))); long[] completed = cols(r.completed(), false); long[] recovered = cols(r.recovered(), false); long[] handled = cols(r.handled(), false); @@ -463,32 +539,47 @@ class TimelineTab extends AbstractTableTab { max = Math.max(max, total(completed[c], recovered[c], handled[c], failed[c])); } String[] events = eventsOf(r.events(), per); + List<List<Span>> rows = new ArrayList<>(); + for (int row = 0; row < strip; row++) { + boolean bottom = row == strip - 1; + List<Span> spans = new ArrayList<>(); + String text = bottom ? (selected ? ">> " : " ") + name : ""; + spans.add(selected ? Span.styled(pad(text, label), Theme.selectionBg()) : Span.raw(pad(text, label))); + rows.add(spans); + } for (int c = 0; c < columns; c++) { long t = total(completed[c], recovered[c], handled[c], failed[c]); String event = c / width < events.length && c % width == 0 ? events[c / width] : null; - if (event != null) { - // what happened to the route then, in place of its exchanges - spans.add(eventMark(event)); - } else if (down[c] > 0) { - spans.add(Span.styled("โ", Theme.error())); - } else if (open[c] > 0) { - // the circuit breaker was open (half open dim): the calls went to the fallback, or failed fast - spans.add(Span.styled("โ", open[c] >= 2 ? Theme.notice() : Theme.muted())); - } else if (completed[c] < 0) { - spans.add(Span.raw(" ")); - } else if (t == 0) { - spans.add(Span.styled("ยท", Theme.muted())); - } else { - int level = (int) Math.max(1, Math.min(8, Math.ceil(t * 8.0 / max))); - Style style = failed[c] > 0 ? Theme.error() - : handled[c] > 0 ? handledStyle() - : recovered[c] > 0 || attempts[c] > 0 ? Theme.warning() : Theme.success(); - spans.add(Span.styled(String.valueOf(BLOCKS.charAt(level - 1)), style)); + Style style = failed[c] > 0 ? Theme.error() + : handled[c] > 0 ? handledStyle() + : recovered[c] > 0 || attempts[c] > 0 ? Theme.warning() : Theme.success(); + int level = t == 0 ? 0 : (int) Math.max(1, Math.min(8L * strip, Math.ceil(t * 8.0 * strip / max))); + for (int row = 0; row < strip; row++) { + boolean bottom = row == strip - 1; + Span cell; + if (event != null) { + // what happened to the route then, in place of its exchanges + cell = bottom ? eventMark(event) : Span.raw(" "); + } else if (down[c] > 0) { + cell = Span.styled("โ", Theme.error()); + } else if (open[c] > 0) { + // the circuit breaker was open (half open dim): the calls went to the fallback, or failed fast + cell = Span.styled("โ", open[c] >= 2 ? Theme.notice() : Theme.muted()); + } else if (completed[c] < 0) { + cell = Span.raw(" "); + } else if (t == 0) { + cell = bottom ? Span.styled("ยท", Theme.muted()) : Span.raw(" "); + } else { + int here = Math.max(0, Math.min(8, level - (strip - 1 - row) * 8)); + cell = here == 0 ? Span.raw(" ") : Span.styled(String.valueOf(BLOCKS.charAt(here - 1)), style); + } + rows.get(row).add(cell); } } - spans.add(Span.raw(" ")); - spans.addAll(summary(r)); - return Line.from(spans); + List<Span> last = rows.get(strip - 1); + last.add(Span.raw(" ")); + last.addAll(summary(r)); + return rows.stream().map(Line::from).toList(); } /** The event of each column of several seconds: the last in the column. */ @@ -609,18 +700,20 @@ class TimelineTab extends AbstractTableTab { // ---- the selected route ---- private void renderDetail( - Frame frame, Rect area, Timeline.RouteSeries r, int columns, int per, int label, IntegrationInfo info) { + Frame frame, Rect area, Timeline.RouteSeries r, int columns, int label, IntegrationInfo info, Room room) { List<Line> lines = new ArrayList<>(); + Padding padding = room.roomy() ? Padding.uniform(1) : Padding.NONE; if (r == null) { frame.renderWidget(Paragraph.builder().text(Text.from(Line.from(Span.raw("")))) - .block(block(" Route ")).build(), area); + .block(block(" Route ", padding)).build(), area); return; } List<long[]> values = List.of(cols(r.completed(), false), cols(r.recovered(), false), cols(r.handled(), false), cols(r.failed(), false)); List<Style> styles = List.of(Theme.success(), Theme.warning(), handledStyle(), Theme.error()); - int rows = Math.max(2, Math.min(DETAIL_ROWS, area.height() - 7)); + int rows = Math.max(2, Math.min(room.detailRows(), area.height() - 7 - (room.roomy() ? 4 : 0))); lines.addAll(stacked(values, styles, rows, columns, label)); + blank(lines, room.roomy()); long[] mean = cols(r.mean(), true); long maxMean = 0; @@ -685,6 +778,7 @@ class TimelineTab extends AbstractTableTab { callLine.addAll(calls); } lines.add(Line.from(callLine)); + blank(lines, room.roomy()); List<Span> totals = new ArrayList<>(); totals.add(Span.raw(pad("", label))); @@ -694,7 +788,7 @@ class TimelineTab extends AbstractTableTab { addTotal(totals, r.total(r.failed()), "failed", Theme.error()); lines.add(Line.from(totals)); String title = " " + r.route() + " โ per second "; - frame.renderWidget(Paragraph.builder().text(Text.from(lines)).block(block(title)).build(), area); + frame.renderWidget(Paragraph.builder().text(Text.from(lines)).block(block(title, padding)).build(), area); } private static void addTotal(List<Span> spans, long count, String words, Style style) { @@ -823,8 +917,8 @@ class TimelineTab extends AbstractTableTab { return s.length() >= width ? s : " ".repeat(width - s.length()) + s; } - private static Block block(String title) { - return Block.builder().borderType(BorderType.ROUNDED).borders(Borders.ALL).title(title).build(); + private static Block block(String title, Padding padding) { + return Block.builder().borderType(BorderType.ROUNDED).borders(Borders.ALL).title(title).padding(padding).build(); } @Override diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/timeline.md b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/timeline.md index c430256bfaac..59ce27f2c213 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/timeline.md +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/timeline.md @@ -40,6 +40,8 @@ When nothing went wrong, the band is one line: `Errors/s none`. A line per route, one character per second. The height is the number of exchanges done that second, relative to the busiest second of the route. +On a tall screen the lines are two rows high with a blank line between +them, the charts are taller, and there is room around the panels. The colour is the worst that happened to them that second: - green: completed diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TimelineTabRenderTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TimelineTabRenderTest.java index 257389c061fd..ee381eead6d1 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TimelineTabRenderTest.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TimelineTabRenderTest.java @@ -22,10 +22,13 @@ import java.util.List; import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicReference; +import dev.tamboui.layout.Rect; import dev.tamboui.text.Span; import dev.tamboui.tui.event.KeyCode; import dev.tamboui.tui.event.KeyEvent; import dev.tamboui.tui.event.KeyModifiers; +import dev.tamboui.tui.event.MouseButton; +import dev.tamboui.tui.event.MouseEvent; import org.apache.camel.util.json.JsonArray; import org.apache.camel.util.json.JsonObject; import org.junit.jupiter.api.BeforeEach; @@ -319,4 +322,74 @@ class TimelineTabRenderTest { spans.forEach(s -> sb.append(s.content())); return sb.toString(); } + + @Test + void aTallScreenHasRoomAroundThePanelsAndTallerStrips() { + TimelineTab tab = new TimelineTab(ctx, now::get); + List<String> lines = TuiTestHelper.renderToString(tab, 198, 48).lines().toList(); + + // a line between the panels + int bottom = indexOf(lines, "โฐ"); + assertThat(lines.get(bottom + 1)).isBlank(); + assertThat(lines.get(bottom + 2)).contains("orders โ per second"); + // a strip is 2 rows: the blocks of a busy second stand on the row with the name + int orders = indexOf(lines, ">> orders"); + assertThat(lines.get(orders - 1)).contains("โโโโ"); + // and a blank line between the routes + int shipping = indexOf(lines, " shipping"); + assertThat(shipping).isEqualTo(orders + 3); + assertThat(lines.get(orders + 1).replace("โ", "")).isBlank(); + // the charts grow: the scale of the errors per second is 6 rows from top to bottom + int top = indexOf(lines, "2 โค"); + assertThat(lines.get(top + 5)).contains("0 โค"); + } + + @Test + void aSmallScreenStaysCompact() { + TimelineTab tab = new TimelineTab(ctx, now::get); + List<String> lines = TuiTestHelper.renderToString(tab, 160, 30).lines().toList(); + + int bottom = indexOf(lines, "โฐ"); + assertThat(lines.get(bottom + 1)).contains("orders โ per second"); + int orders = indexOf(lines, ">> orders"); + assertThat(lines.get(orders + 1)).contains(" shipping"); + } + + @Test + void theRoomGoesToTheSpaceAroundThenTheRoutesThenTheCharts() { + assertThat(TimelineTab.room(30, 3, true)).isEqualTo(TimelineTab.Room.COMPACT); + assertThat(TimelineTab.room(48, 3, true)).isEqualTo(new TimelineTab.Room(true, 6, 6, 2, true)); + // the routes need the rows: one row each, no blank line between them + TimelineTab.Room many = TimelineTab.room(48, 10, true); + assertThat(many.roomy()).isTrue(); + assertThat(many.strip()).isEqualTo(1); + assertThat(many.spaced()).isFalse(); + // no error: the detail has the rows the errors would have + assertThat(TimelineTab.room(48, 3, false).causeRows()).isEqualTo(4); + } + + @Test + void aClickOnTheStripOfARouteSelectsIt() { + TimelineTab tab = new TimelineTab(ctx, now::get); + Rect area = new Rect(0, 0, 198, 48); + List<String> lines = TuiTestHelper.renderToString(tab, 198, 48).lines().toList(); + int shipping = indexOf(lines, " shipping"); + + // the blank line between orders and shipping is no route + assertThat(tab.handleMouseEvent(MouseEvent.press(MouseButton.LEFT, 40, shipping - 2), area)).isFalse(); + // the upper row of the strip of shipping is shipping + assertThat(tab.handleMouseEvent(MouseEvent.press(MouseButton.LEFT, 40, shipping - 1), area)).isTrue(); + assertThat(tab.getTableDataAsJson().getInteger("selectedIndex")).isEqualTo(1); + assertThat(tab.handleMouseEvent(MouseEvent.press(MouseButton.LEFT, 40, shipping + 3), area)).isTrue(); + assertThat(tab.getTableDataAsJson().getInteger("selectedIndex")).isEqualTo(2); + } + + private static int indexOf(List<String> lines, String text) { + for (int i = 0; i < lines.size(); i++) { + if (lines.get(i).contains(text)) { + return i; + } + } + throw new AssertionError("no line with " + text); + } }
