This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch camel-jbang-diagram-zoom in repository https://gitbox.apache.org/repos/asf/camel.git
commit 9248e5e3999c57321aaab6cc5fb374151e1199c3 Author: Claus Ibsen <[email protected]> AuthorDate: Sun Oct 25 13:57:34 2026 +0100 chore: camel-jbang - zoom levels and folding for the route diagram A large route takes a lot of screen. The route diagram gets three zoom levels (+ and -): detailed (as before), compact (a box with one line, the steps closer together) and bare (a mark and the text of each step on one row). z fits the most detail in which the whole route fits the screen. Space folds the block of the selected step into its first step, f folds the outermost blocks or unfolds all; a folded block is left out before the layout, so it gives its rows and columns back. The selected step stays selected and on its row of the screen. The level is kept for the next start, and shared by the Diagram and Routes tabs. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../ROOT/pages/camel-jbang-tui-diagram.adoc | 24 +- .../core/commands/tui/AbstractDiagramTab.java | 47 ++++ .../jbang/core/commands/tui/DiagramSupport.java | 33 ++- .../dsl/jbang/core/commands/tui/DiagramTab.java | 3 + .../jbang/core/commands/tui/MonitorContext.java | 2 + .../jbang/core/commands/tui/RouteDiagramZoom.java | 299 +++++++++++++++++++++ .../dsl/jbang/core/commands/tui/RoutesTab.java | 4 + .../core/commands/tui/diagram/RouteDensity.java | 181 +++++++++++++ .../commands/tui/diagram/RouteDiagramWidget.java | 210 +++++++++++++-- .../src/main/resources/tui/help/diagram.md | 12 +- .../src/main/resources/tui/help/routes.md | 9 + .../core/commands/tui/RouteDiagramZoomTest.java | 178 ++++++++++++ .../commands/tui/diagram/RouteDensityTest.java | 180 +++++++++++++ 13 files changed, 1136 insertions(+), 46 deletions(-) diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-diagram.adoc b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-diagram.adoc index 6fc27a5b1c34..185de8ae945f 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-diagram.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-diagram.adoc @@ -55,6 +55,24 @@ Press *t* to jump straight back to the topology from any depth. image::jbang/camel-tui-diagram-route.png[The checkout route drilled into, with the level bar on top] +=== Zoom and Folding + +A large route takes a lot of screen. Press *-* to zoom out and *+* to zoom in, through three levels: + +* *detailed*: a box per step, with up to three lines of text +* *compact*: a box per step with one line of text, and the steps closer together: about twice as many steps fit +* *bare*: no boxes, each step is a mark and its text on one row (`▶` the from, `◆` a step with steps in it, `◇` a +branch, `●` a step), with the counts beside the lines between them + +Press *z* for fit: the most detailed level in which the whole route fits the screen. The view bar shows the level +(`zoom: compact`, or `zoom: fit bare`). Zooming keeps the selected step selected, on the same row of the screen, and +the Info panel has the whole text of the selected step at every level. + +Press *Space* to fold a block (a choice, one of its branches, a split, a doTry, a filter...) into its first step, +which then says how many steps are folded (`▸ 12 steps folded`); *Space* on a step inside a block folds that block, and +*Space* on a folded step unfolds it. A folded block gives its rows and its columns back to the rest of the route. +Press *f* to fold the outermost blocks of the route, and *f* again to unfold them all. + === Diagram levels The Diagram tab shows one diagram at three zoom levels, and the line above it says which one you see: @@ -86,7 +104,7 @@ labels and what capabilities achieve (the AI-assisted parts marked ✦), or rout endpoints and the entry points and systems of each capability. The business view is the default when the routes have descriptions, from their source or the AI project overview; *n*, the key of the former description toggle, does the same. *m* metrics and *e* external systems apply to the topology, *m*, -*y* body types and *d* detail to a route. *g* group, in the topology, shows the architecture one level down: each route +*y* body types, *+/-* zoom (see <<_zoom_and_folding,Zoom and Folding>>) and *d* detail to a route. *g* group, in the topology, shows the architecture one level down: each route box names its group (a route group, an AI capability marked ✦, shared services or utility), each group has the border colour of its box in the architecture, and a legend at the bottom of the topology lists the groups in their colours; it is on when the project has groups. *u* shows or hides the utility @@ -247,6 +265,10 @@ camel-xml-io, while a Spring Boot or Quarkus application may not. | *t* | Jump to topology view | *m* | Toggle metrics overlay | *y* | In a route: show the body types each step has been reached with, beside the arrows into the steps +| *+* / *-* | In a route: zoom in or out (detailed, compact, bare) +| *z* | In a route: fit, the most detail in which the whole route fits the screen +| *Space* | In a route: fold the block of the selected step, or unfold a folded step +| *f* | In a route: fold the outermost blocks, or unfold all | *e* | External systems: in the topology off, edges, all; in the architecture off or edges | *a* | AI-assisted parts shown, or the facts alone; without a `camel-summary.md` yet, has the AI write one | *b* (or *n*) | Business view (labels, what capabilities achieve) or technical view (route ids, endpoints) diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AbstractDiagramTab.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AbstractDiagramTab.java index 7cd960f2cdc2..f28394a96b9a 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AbstractDiagramTab.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AbstractDiagramTab.java @@ -58,6 +58,7 @@ abstract class AbstractDiagramTab extends AbstractTab { protected AbstractDiagramTab(MonitorContext ctx) { super(ctx); + diagram.zoom = ctx.routeZoom; } boolean isShowDiagram() { @@ -131,6 +132,9 @@ abstract class AbstractDiagramTab extends AbstractTab { gotoNodePopup.open(diagram.getEipNodeBoxes(), diagram.getSelectedEipNodeIndex()); return true; } + if (routeNodes && handleZoomKeys(ke)) { + return true; + } if (!topologyMode && detailMode) { if (ke.isPageUp() || ke.isKey(KeyCode.PAGE_UP)) { detail.scrollBy(-10); @@ -187,6 +191,49 @@ abstract class AbstractDiagramTab extends AbstractTab { return false; } + /** + * The zoom of the route diagram: + and - give the steps more or less room, z the most detail that fits the screen, + * space folds the block of the selected step (or unfolds it), f folds the outermost blocks or unfolds all. The + * selected step stays selected, on the same row of the screen. + */ + private boolean handleZoomKeys(KeyEvent ke) { + RouteDiagramZoom zoom = diagram.zoom; + var selected = diagram.getSelectedEipNodeBox(); + String anchorId = selected != null ? selected.nodeId() : null; + if (ke.isChar('+') || ke.isChar('=')) { + zoom.zoomIn(); + ctx.backgroundExecutor.execute(zoom::persist); + } else if (ke.isChar('-')) { + zoom.zoomOut(); + ctx.backgroundExecutor.execute(zoom::persist); + } else if (ke.isCharIgnoreCase('z')) { + zoom.toggleFit(); + ctx.backgroundExecutor.execute(zoom::persist); + } else if (ke.isChar(' ')) { + String folded = zoom.toggleFold(drillDownRouteId, selected != null ? selected.layoutNode() : null); + if (folded == null) { + // no block to fold + return true; + } + anchorId = folded; + } else if (ke.isChar('f')) { + zoom.toggleFoldAll(drillDownRouteId, diagram.getRouteLayout(drillDownRouteId)); + } else { + return false; + } + int screenRow = 0; + for (var box : diagram.getEipNodeBoxes()) { + if (anchorId != null && anchorId.equals(box.nodeId())) { + screenRow = box.startRow() - diagram.getScrollY(); + break; + } + } + zoom.anchor(diagram.getRouteLayout(drillDownRouteId), anchorId, screenRow); + diagram.endLoad(); + reloadDiagram(); + return true; + } + /** * Esc on the diagram: closes the go to node popup or the source, else from a route goes back along the routes * followed, then to the topology. Returns false in the topology, where each tab has its own. diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramSupport.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramSupport.java index 7866de503162..dd06303792d1 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramSupport.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramSupport.java @@ -64,6 +64,7 @@ import org.apache.camel.diagram.TopologyLayoutEngine.TopologyLayoutNode; import org.apache.camel.diagram.TopologyLayoutEngine.TopologyLayoutResult; import org.apache.camel.diagram.TopologyLayoutEngine.TopologyNodeInfo; import org.apache.camel.dsl.jbang.core.commands.ai.IntegrationSummary; +import org.apache.camel.dsl.jbang.core.commands.tui.diagram.RouteDensity; import org.apache.camel.dsl.jbang.core.commands.tui.diagram.RouteDiagramWidget; import org.apache.camel.dsl.jbang.core.commands.tui.diagram.TopologyDiagramWidget; import org.apache.camel.util.json.JsonArray; @@ -131,6 +132,8 @@ class DiagramSupport { private TopologyLayoutResult topologyLayout; private int topologyNodeWidth; private java.util.Map<String, RouteDiagramLayoutEngine.LayoutRoute> routeLayouts = Collections.emptyMap(); + /** The zoom of the route diagram, and its folded blocks; the tabs of the route diagram share theirs. */ + RouteDiagramZoom zoom = new RouteDiagramZoom(); private String cachedPid; private volatile boolean preloading; @@ -979,7 +982,7 @@ class DiagramSupport { } /** The error frame of a route in the route view: its onException blocks, where its failures go and come from. */ - private static RouteErrorFrame errorFrame( + static RouteErrorFrame errorFrame( RouteDiagramLayoutEngine engine, RouteDiagramLayoutEngine.LayoutRoute lr, List<RouteDiagramLayoutEngine.RouteInfo> blocks, String routeId, List<TopologyErrorFrame.ErrorPath> paths) { return RouteErrorFrame.add(engine, lr, blocks, @@ -1547,10 +1550,18 @@ class DiagramSupport { Map<String, String> linkable = computeLinkableEndpoints(currentRouteId); Map<String, String> routeDescs = showDescription ? computeRouteDescriptions() : Collections.emptyMap(); + RouteDiagramZoom.Restore restore = zoom.restore(routeLayout); + if (restore != null) { + // zoomed or folded: the selected step stays on its row of the screen, unless the whole route fits on it + selectedEipNodeIndex = restore.index(); + boolean fits = RouteDensity.rows(routeLayout) <= lastVisibleHeight; + scrollY = fits ? 0 : Math.max(0, restore.row() - restore.screenRow()); + } // built again once the scroll is clamped to its size Supplier<RouteDiagramWidget> widgets = () -> new RouteDiagramWidget( routeLayout, nw, selectedEipNodeIndex, scrollX, scrollY, metrics, linkable, showDescription, routeDescs) + .withDensity(zoom.densityOf(routeLayout)).withFoldedSteps(zoom.foldedStepsOf(routeLayout)) .withErrorFrame(layoutShowErrorPaths ? routeErrorFrames.get(currentRouteId) : null) .withErrorMark(errorMarkRouteIds().contains(currentRouteId)) .withBodyTypes(showBodyTypes).withBodyKindMarks(true); @@ -2286,23 +2297,9 @@ class DiagramSupport { RouteDiagramLayoutEngine.NodeLabelMode labelMode = showDescription ? RouteDiagramLayoutEngine.NodeLabelMode.DESCRIPTION : RouteDiagramLayoutEngine.NodeLabelMode.CODE; - RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine( - RouteDiagramLayoutEngine.DEFAULT_BOX_WIDTH, RouteDiagramLayoutEngine.DEFAULT_FONT_SIZE, - labelMode); - engine.setTableLayout(true); - for (RouteDiagramLayoutEngine.RouteInfo r : routes) { - // the happy path; the onException clauses are not its steps, and are shown in the error frame - RouteErrorFrame.Split split = RouteErrorFrame.split(r); - RouteDiagramLayoutEngine.LayoutRoute lr = engine.layoutRoute(split.happy(), 0); - normalizeRouteLayoutY(lr); - if (showErrors) { - RouteErrorFrame frame = errorFrame(engine, lr, split.blocks(), r.routeId, loadedErrorPaths); - if (frame != null) { - loadedRouteFrames.put(r.routeId, frame); - } - } - routeMap.put(r.routeId, lr); - } + // folded and zoomed (+ - z space), with the error handling in its frame below + routeMap.putAll(zoom.layoutRoutes( + routes, labelMode, showErrors, loadedErrorPaths, loadedRouteFrames, lastVisibleHeight)); } } diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramTab.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramTab.java index 3856d58299f3..c4926553ef51 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramTab.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramTab.java @@ -511,6 +511,7 @@ class DiagramTab extends AbstractDiagramTab { yield toggles; } case ROUTE -> List.of(view, new SubViewBar.Toggle("x", "errors", errorsToggle()), + new SubViewBar.Toggle("+/-", "zoom", diagram.zoom.label(diagram.getRouteLayout(drillDownRouteId))), new SubViewBar.Toggle("m", "metrics", metrics), new SubViewBar.Toggle("y", "types", diagram.isShowBodyTypes() ? "on" : "off"), new SubViewBar.Toggle("d", "detail", detailMode ? "on" : "off")); @@ -661,6 +662,8 @@ class DiagramTab extends AbstractDiagramTab { hint(spans, "Esc", "back"); hint(spans, "c", "source"); hint(spans, "g", "go to"); + hint(spans, "+/-", "zoom"); + hint(spans, "Space", "fold"); hint(spans, "s", "summary"); } else if (!topologyMode) { hint(spans, "Esc", "back"); diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorContext.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorContext.java index 4488a2f22881..08d1dd14806a 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorContext.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorContext.java @@ -61,6 +61,8 @@ class MonitorContext { }); /** The structure of the routes the diagrams draw, asked of an integration again only when its routes change. */ final DiagramTopology diagramTopology = new DiagramTopology(); + /** The zoom and the folded blocks of the route diagram, one for the Diagram and the Routes tabs. */ + final RouteDiagramZoom routeZoom = RouteDiagramZoom.persisted(); volatile String selectedPid; volatile String lastSelectedName; diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RouteDiagramZoom.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RouteDiagramZoom.java new file mode 100644 index 000000000000..781c57633f12 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RouteDiagramZoom.java @@ -0,0 +1,299 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.dsl.jbang.core.commands.tui; + +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.WeakHashMap; +import java.util.concurrent.ConcurrentHashMap; + +import org.apache.camel.diagram.RouteDiagramLayoutEngine; +import org.apache.camel.diagram.RouteDiagramLayoutEngine.LayoutNode; +import org.apache.camel.diagram.RouteDiagramLayoutEngine.LayoutRoute; +import org.apache.camel.diagram.RouteDiagramLayoutEngine.NodeInfo; +import org.apache.camel.diagram.RouteDiagramLayoutEngine.RouteInfo; +import org.apache.camel.diagram.RouteDiagramLayoutEngine.TreeNode; +import org.apache.camel.diagram.RouteErrorFrame; +import org.apache.camel.diagram.TopologyErrorFrame; +import org.apache.camel.dsl.jbang.core.commands.tui.diagram.RouteDensity; + +/** + * The zoom of the route diagram: how much room a step takes ({@link RouteDensity}), or the most detail that fits the + * screen, and the blocks folded into their first step. A folded block is left out of the route before the engine lays + * it out, so the steps after it move up and its branches give their columns back. + * <p> + * The keys change the zoom on the UI thread; the layout reads it on the background thread, so the state is volatile or + * concurrent. When the zoom changes, the selected step stays selected and on the same row of the screen. + */ +final class RouteDiagramZoom { + + /** The zoom chosen last: a density, or fit; the folded blocks are not kept. */ + static final String PROP_ROUTE_ZOOM = "camel.tui.routeZoom"; + + /** The selected step to select again once the route is laid out anew, and its row on the screen. */ + record Restore(int index, int row, int screenRow) { + } + + private volatile RouteDensity density = RouteDensity.DETAILED; + private volatile boolean fit; + /** The folded blocks of each route: the ids of their first steps. */ + private final Map<String, Set<String>> folded = new ConcurrentHashMap<>(); + /** What each layout was made with: its density, and the steps folded into each folded step. */ + private final Map<LayoutRoute, RouteDensity> densities = Collections.synchronizedMap(new WeakHashMap<>()); + private final Map<LayoutRoute, Map<String, Integer>> foldedSteps + = Collections.synchronizedMap(new WeakHashMap<>()); + // the step to select again after the next layout, and the layout it was selected on + private LayoutRoute anchorLayout; + private String anchorNodeId; + private int anchorScreenRow; + + /** The zoom with the density or fit chosen last time. */ + static RouteDiagramZoom persisted() { + RouteDiagramZoom zoom = new RouteDiagramZoom(); + try { + zoom.setting(TuiUserConfig.read(PROP_ROUTE_ZOOM)); + } catch (RuntimeException e) { + // best-effort: the default zoom + } + return zoom; + } + + /** Keeps the density or fit for the next start; file work, so not on the UI thread. */ + void persist() { + try { + TuiUserConfig.write(PROP_ROUTE_ZOOM, setting()); + } catch (RuntimeException e) { + // best-effort: a persist failure must not disrupt the TUI + } + } + + /** The density, or fit; null for the default (detailed), so the file does not carry it. */ + String setting() { + if (fit) { + return "fit"; + } + return density == RouteDensity.DETAILED ? null : density.label(); + } + + void setting(String value) { + fit = "fit".equals(value); + density = RouteDensity.DETAILED; + for (RouteDensity d : RouteDensity.values()) { + if (d.label().equals(value)) { + density = d; + } + } + } + + RouteDensity density() { + return density; + } + + boolean isFit() { + return fit; + } + + void zoomIn() { + fit = false; + density = density.zoomIn(); + } + + void zoomOut() { + fit = false; + density = density.zoomOut(); + } + + void toggleFit() { + fit = !fit; + } + + /** What the zoom toggle of the view bar says: the density, and fit when it is chosen for the screen. */ + String label(LayoutRoute shown) { + RouteDensity d = shown != null ? densityOf(shown) : density; + return fit ? "fit " + d.label() : d.label(); + } + + RouteDensity densityOf(LayoutRoute lr) { + RouteDensity d = densities.get(lr); + return d != null ? d : RouteDensity.DETAILED; + } + + Map<String, Integer> foldedStepsOf(LayoutRoute lr) { + Map<String, Integer> steps = foldedSteps.get(lr); + return steps != null ? steps : Map.of(); + } + + /** + * Folds the block the step starts, or unfolds it when folded; a step without a block of its own folds the block it + * is in. + * + * @return the id of the step folded or unfolded, to select; null when there is no block to fold + */ + String toggleFold(String routeId, LayoutNode node) { + if (routeId == null || node == null || node.treeNode == null || node.id == null) { + return null; + } + Set<String> ids = folded.computeIfAbsent(routeId, k -> ConcurrentHashMap.newKeySet()); + if (ids.remove(node.id)) { + return node.id; + } + TreeNode block = node.treeNode; + while (block != null && !foldable(block)) { + block = block.parent; + } + if (block == null || block.info.id == null) { + return null; + } + ids.add(block.info.id); + return block.info.id; + } + + /** Folds the outermost blocks of the route, or unfolds all of them when some are folded already. */ + void toggleFoldAll(String routeId, LayoutRoute lr) { + Set<String> ids = folded.computeIfAbsent(routeId, k -> ConcurrentHashMap.newKeySet()); + if (!ids.isEmpty()) { + ids.clear(); + return; + } + if (lr != null && !lr.nodes.isEmpty() && lr.nodes.get(0).treeNode != null) { + foldOutermost(lr.nodes.get(0).treeNode, ids); + } + } + + private static void foldOutermost(TreeNode node, Set<String> ids) { + for (TreeNode child : node.children) { + if (foldable(child) && child.info.id != null) { + ids.add(child.info.id); + } else { + foldOutermost(child, ids); + } + } + } + + /** A step with steps in it, which is not the route or its from. */ + private static boolean foldable(TreeNode node) { + return !node.children.isEmpty() && !node.table && node.info.type != null + && !"route".equals(node.info.type) && !"from".equals(node.info.type); + } + + /** Remembers the selected step and its row on the screen, to keep it there once the route is laid out anew. */ + void anchor(LayoutRoute shown, String nodeId, int screenRow) { + anchorLayout = shown; + anchorNodeId = nodeId; + anchorScreenRow = screenRow; + } + + /** The step to select in the layout to draw, when it is the new layout the anchor waited for; else null. */ + Restore restore(LayoutRoute lr) { + if (anchorNodeId == null || lr == null || lr == anchorLayout) { + return null; + } + String id = anchorNodeId; + anchorNodeId = null; + anchorLayout = null; + int index = 0; + for (LayoutNode ln : lr.nodes) { + if ("route".equals(ln.type)) { + continue; + } + if (id.equals(ln.id)) { + return new Restore(index, ln.y / RouteDensity.ROW, anchorScreenRow); + } + index++; + } + return null; + } + + /** + * Lays out the routes with the zoom: the folded blocks left out, the steps on the rows of the density (with fit, + * the most detailed one in which the route fits the rows of the screen), the error handling in its frame below. + */ + Map<String, LayoutRoute> layoutRoutes( + List<RouteInfo> routes, RouteDiagramLayoutEngine.NodeLabelMode labelMode, boolean showErrors, + List<TopologyErrorFrame.ErrorPath> errorPaths, Map<String, RouteErrorFrame> frames, int visibleRows) { + RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine( + RouteDiagramLayoutEngine.DEFAULT_BOX_WIDTH, RouteDiagramLayoutEngine.DEFAULT_FONT_SIZE, labelMode); + // a Switch is drawn as a decision table, a row per case + engine.setTableLayout(true); + Map<String, LayoutRoute> answer = new LinkedHashMap<>(); + for (RouteInfo r : routes) { + // the happy path; the onException clauses are not its steps, and are shown in the error frame + RouteErrorFrame.Split split = RouteErrorFrame.split(r); + Map<String, Integer> steps = new HashMap<>(); + LayoutRoute lr = engine.layoutRoute(fold(split.happy(), steps), 0); + RouteDensity d = fit ? RouteDensity.DETAILED : density; + // each density places the steps from where the engine put the route, before it was moved to the top + int rootY = lr.nodes.isEmpty() ? 0 : lr.nodes.get(0).y; + d.apply(lr); + DiagramLayouts.normalizeRouteLayoutY(lr); + while (fit && d != RouteDensity.BARE && visibleRows > 0 && RouteDensity.rows(lr) > visibleRows) { + d = d.zoomOut(); + lr.nodes.get(0).y = rootY; + d.apply(lr); + DiagramLayouts.normalizeRouteLayoutY(lr); + } + if (showErrors) { + RouteErrorFrame frame = DiagramSupport.errorFrame(engine, lr, split.blocks(), r.routeId, errorPaths); + if (frame != null) { + frames.put(r.routeId, frame); + } + } + densities.put(lr, d); + foldedSteps.put(lr, steps); + answer.put(r.routeId, lr); + } + return answer; + } + + /** The route without the steps of its folded blocks; counts the steps left out of each. */ + private RouteInfo fold(RouteInfo route, Map<String, Integer> steps) { + Set<String> ids = folded.getOrDefault(route.routeId, Set.of()); + if (ids.isEmpty()) { + return route; + } + RouteInfo answer = new RouteInfo(); + answer.routeId = route.routeId; + answer.source = route.source; + answer.stat = route.stat; + Set<String> present = new HashSet<>(); + int foldLevel = -1; + String foldId = null; + for (NodeInfo n : route.nodes) { + if (n.id != null) { + present.add(n.id); + } + if (foldLevel >= 0 && n.level > foldLevel) { + steps.merge(foldId, 1, Integer::sum); + continue; + } + foldLevel = -1; + answer.nodes.add(n); + if (n.id != null && ids.contains(n.id)) { + foldLevel = n.level; + foldId = n.id; + } + } + // a step no longer in the route (its source changed) is not folded any more + ids.retainAll(present); + return answer; + } +} diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RoutesTab.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RoutesTab.java index 9663de324354..04248a6e0591 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RoutesTab.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RoutesTab.java @@ -735,6 +735,8 @@ class RoutesTab extends AbstractDiagramTab { "e", "external", EXTERNAL_LABELS[externalMode].trim().replace("[", "").replace("]", ""))); } else if (!diagram.getEipNodeBoxes().isEmpty()) { + toggles.add(new SubViewBar.Toggle( + "+/-", "zoom", diagram.zoom.label(diagram.getRouteLayout(drillDownRouteId)))); toggles.add(new SubViewBar.Toggle("y", "types", diagram.isShowBodyTypes() ? "on" : "off")); toggles.add(new SubViewBar.Toggle("d", "detail", detailMode ? "on" : "off")); } @@ -756,6 +758,8 @@ class RoutesTab extends AbstractDiagramTab { hint(spans, "t", "topology"); hint(spans, TuiIcons.HINT_NAV, "navigate"); hint(spans, "g", "go to"); + hint(spans, "+/-", "zoom"); + hint(spans, "Space", "fold"); hint(spans, "PgUp/PgDn", detailMode ? "detail" : "page"); hint(spans, "c", "source"); } else if (!topologyMode) { diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/RouteDensity.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/RouteDensity.java new file mode 100644 index 000000000000..c671d9c9eb9f --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/RouteDensity.java @@ -0,0 +1,181 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.dsl.jbang.core.commands.tui.diagram; + +import org.apache.camel.diagram.RouteDiagramLayoutEngine; +import org.apache.camel.diagram.RouteDiagramLayoutEngine.LayoutNode; +import org.apache.camel.diagram.RouteDiagramLayoutEngine.LayoutRoute; +import org.apache.camel.diagram.RouteDiagramLayoutEngine.TreeNode; + +import static org.apache.camel.diagram.RouteDiagramLayoutEngine.BRANCH_CHILD_TYPES; + +/** + * How much room a step of the route diagram takes: the zoom levels of a route. A terminal cannot make its font smaller, + * so zooming out draws a step with fewer rows and puts the steps closer together. + * <ul> + * <li>{@link #DETAILED}: a box with up to three lines of text, as the layout engine places it</li> + * <li>{@link #COMPACT}: a box with one line of text, an arrow of a row between the boxes</li> + * <li>{@link #BARE}: no box, a step is a mark and its text on one row</li> + * </ul> + * The engine keeps the columns of the steps; {@link #apply} places them again on the rows of the density, top down as + * the engine does, in its pixels (a row is {@link #ROW} of them). A block (a step with steps in it) takes a row above + * it for the top of its frame (compact), and a row below it for the bottom of its frame (compact) or a blank row + * (bare). + */ +public enum RouteDensity { + + DETAILED("detailed", 0, 0, 0, 0, 0), + COMPACT("compact", 3, 1, 2, Px.ROW, Px.ROW), + BARE("bare", 1, 1, 2, 0, Px.ROW); + + /** The pixels of the layout in a row of the terminal (the widget divides by it). */ + public static final int ROW = Px.ROW; + + private final String label; + private final int nodeRows; + private final int gap; + private final int branchGap; + private final int scopeTop; + private final int scopeBottom; + + RouteDensity(String label, int nodeRows, int gapRows, int branchGapRows, int scopeTop, int scopeBottom) { + this.label = label; + this.nodeRows = nodeRows; + this.gap = gapRows * Px.ROW; + this.branchGap = branchGapRows * Px.ROW; + this.scopeTop = scopeTop; + this.scopeBottom = scopeBottom; + } + + public String label() { + return label; + } + + /** One level more detail, or this one when it is the most. */ + public RouteDensity zoomIn() { + return this == DETAILED ? DETAILED : values()[ordinal() - 1]; + } + + /** One level less detail, or this one when it is the least. */ + public RouteDensity zoomOut() { + return this == BARE ? BARE : values()[ordinal() + 1]; + } + + /** Whether the steps are drawn in boxes; the bare steps are a mark and a text, with no box around their blocks. */ + public boolean boxed() { + return this != BARE; + } + + /** The rows the diagram of the route takes. */ + public static int rows(LayoutRoute lr) { + return lr.maxY / ROW; + } + + /** Places the steps of a route laid out by the engine on the rows of this density; the detailed is the engine's. */ + public void apply(LayoutRoute lr) { + if (this == DETAILED || lr.nodes.isEmpty() || lr.nodes.get(0).treeNode == null) { + return; + } + lr.maxY = 0; + LayoutNode root = lr.nodes.get(0); + place(root.treeNode, root.y, lr); + } + + private void place(TreeNode node, int y, LayoutRoute lr) { + LayoutNode ln = node.layoutNode; + ln.y = y; + ln.height = height(node); + lr.maxY = Math.max(lr.maxY, y + ln.height); + if (node.table) { + // the rows are inside the table's box + for (TreeNode row : node.children) { + row.layoutNode.y = y; + } + return; + } + if (node.children.isEmpty()) { + return; + } + boolean branching = RouteDiagramLayoutEngine.isBranchingEip(node.info.type); + int childY = y + ln.height + (branching ? branchGap : gap); + if (branching) { + for (TreeNode child : node.children) { + boolean scope = !child.children.isEmpty() && !BRANCH_CHILD_TYPES.contains(child.info.type); + place(child, scope ? childY + scopeTop : childY, lr); + } + return; + } + int curY = childY; + for (int i = 0; i < node.children.size(); i++) { + TreeNode child = node.children.get(i); + boolean scope = RouteDiagramLayoutEngine.hasScope(child); + place(child, scope ? curY + scopeTop : curY, lr); + int bottom = scope ? blockBottom(child) + scopeBottom : bottom(child); + if (scope && i + 1 < node.children.size()) { + // the next step is reached from the bottom of the block's frame, not from its last step + LayoutNode next = node.children.get(i + 1).layoutNode; + if (next != null && next.connectFromMerge) { + next.mergeY = blockBottom(child); + } + } + curY = bottom + gap; + } + } + + private int height(TreeNode node) { + if (node.table) { + int rows = node.children.size(); + int shown = Math.min(rows, RouteDiagramLayoutEngine.MAX_TABLE_ROWS) + + (rows > RouteDiagramLayoutEngine.MAX_TABLE_ROWS ? 1 : 0); + return (2 + shown) * ROW; + } + return nodeRows * ROW; + } + + /** + * The bottom of a step and of all the steps in it, with the frames of the blocks in it: the row of the bottom of + * its own frame. + */ + public int blockBottom(TreeNode node) { + int max = node.layoutNode.y + node.layoutNode.height; + if (node.table) { + return max; + } + for (TreeNode child : node.children) { + int bottom = RouteDiagramLayoutEngine.hasScope(child) ? blockBottom(child) + scopeBottom : blockBottom(child); + max = Math.max(max, bottom); + } + return max; + } + + /** The bottom of a step that has no block of its own, and of the steps under it. */ + private static int bottom(TreeNode node) { + int max = node.layoutNode != null ? node.layoutNode.y + node.layoutNode.height : 0; + if (node.table) { + return max; + } + for (TreeNode child : node.children) { + max = Math.max(max, bottom(child)); + } + return max; + } + + // an enum constant cannot refer to a static field of its own enum in its constructor + private static final class Px { + static final int ROW = 20; + } +} diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/RouteDiagramWidget.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/RouteDiagramWidget.java index b3b3d01a6611..bc6f6a186592 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/RouteDiagramWidget.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/RouteDiagramWidget.java @@ -166,7 +166,7 @@ public class RouteDiagramWidget implements Widget { // Scope boxes (behind everything) for (LayoutNode ln : layoutRoute.nodes) { - if (!"route".equals(ln.type) + if (density.boxed() && !"route".equals(ln.type) && ln.treeNode != null && RouteDiagramLayoutEngine.hasScope(ln.treeNode)) { drawScopeBox(buffer, area, ln); } @@ -266,6 +266,20 @@ public class RouteDiagramWidget implements Widget { private RouteErrorFrame errorFrame; private boolean errorMark; + private RouteDensity density = RouteDensity.DETAILED; + private Map<String, Integer> foldedSteps = Map.of(); + + /** How much room a step takes: the layout must have been placed with the same density. */ + public RouteDiagramWidget withDensity(RouteDensity density) { + this.density = density != null ? density : RouteDensity.DETAILED; + return this; + } + + /** The folded steps, and how many steps are folded into each: a mark beside them says so. */ + public RouteDiagramWidget withFoldedSteps(Map<String, Integer> foldedSteps) { + this.foldedSteps = foldedSteps != null ? foldedSteps : Map.of(); + return this; + } /** The route has error handling: a dim mark on its from box, whether the error handling is shown or not. */ public RouteDiagramWidget withErrorMark(boolean mark) { @@ -430,10 +444,14 @@ public class RouteDiagramWidget implements Widget { } private void drawNode(Buffer buffer, Rect area, LayoutNode node) { + if (!density.boxed()) { + drawBareNode(buffer, area, node); + return; + } int col = toCol(node.x); int row = toRow(node.y); int innerWidth = boxWidth - 4; - List<String> lines = rewrapText(node, innerWidth); + List<String> lines = density == RouteDensity.COMPACT ? List.of(labelText(node)) : rewrapText(node, innerWidth); int height = 2 + lines.size(); int nodeIdx = nodeBoxes.size(); @@ -490,7 +508,9 @@ public class RouteDiagramWidget implements Widget { int textCol = col + 2 + Math.max(0, (innerWidth - CharWidth.of(text)) / 2); // First line: [type] tag with EIP color, rest: label text - if (i == 0) { + if (density == RouteDensity.COMPACT) { + writeLabel(buffer, area, r, textCol, text, eipColor, selected); + } else if (i == 0) { writeText(buffer, area, r, textCol, text, style(Style.EMPTY.fg(eipColor).bold(), selected)); } else { writeText(buffer, area, r, textCol, text, style(fromLabelStyle(), selected)); @@ -505,18 +525,9 @@ public class RouteDiagramWidget implements Widget { // Link indicator for nodes that connect to other routes String linkedRouteId = findLinkedRouteId(node); if (linkedRouteId != null) { - Style linkStyle = Theme.label().bold(); - String name = linkedRouteId; - if (showDescription && "from".equals(node.type)) { - // a from box names its caller in words; a to box already shows the route it links to in words, so - // its marker names the route by id, as the breadcrumb does - String desc = routeDescriptions.get(linkedRouteId); - if (desc != null && !desc.isBlank()) { - name = desc; - } - } - markers.add(new Marker(bottom, col + boxWidth, " ↵ " + name, linkStyle)); + markers.add(new Marker(bottom, col + boxWidth, " ↵ " + linkName(node, linkedRouteId), Theme.label().bold())); } + addFoldedMarker(node, row, col + boxWidth); // where the input comes from and where a message leaves, set in the border so every box keeps its size: // in at the top left (AMQP ──▶), out at the bottom right (──▶ AMQP) if ("from".equals(node.type) && !external && node.treeNode != null && isScheduled(node.treeNode.info)) { @@ -558,7 +569,7 @@ public class RouteDiagramWidget implements Widget { } private void drawMergeArrow(Buffer buffer, Rect area, LayoutNode to) { - int fromCx = toCol(to.mergeCx); + int fromCx = !density.boxed() && to.parentNode != null ? centerCol(to.parentNode) : toCol(to.mergeCx); int fromRow = toRow(to.mergeY); int toCx = centerCol(to); int toTop = getTopRow(to); @@ -598,7 +609,12 @@ public class RouteDiagramWidget implements Widget { for (int r = fromRow; r < toRow - 1; r++) { arrowChar(buffer, area, r, fromCx, vChar, edgeStyle); } - arrowChar(buffer, area, toRow - 1, toCx, ARROW, edgeStyle); + if (density.boxed()) { + arrowChar(buffer, area, toRow - 1, toCx, ARROW, edgeStyle); + } else { + // a branch straight below: through the line to the other branches + joinChar(buffer, area, toRow - 1, toCx, vChar, edgeStyle); + } } else { int midRow = fromRow + (toRow - fromRow) / 2; @@ -608,18 +624,60 @@ public class RouteDiagramWidget implements Widget { int minC = Math.min(fromCx, toCx); int maxC = Math.max(fromCx, toCx); - for (int c = minC; c <= maxC; c++) { - arrowChar(buffer, area, midRow, c, hChar, edgeStyle); + if (density == RouteDensity.DETAILED) { + for (int c = minC; c <= maxC; c++) { + arrowChar(buffer, area, midRow, c, hChar, edgeStyle); + } + arrowChar(buffer, area, midRow, fromCx, T_UP, edgeStyle); + arrowChar(buffer, area, midRow, toCx, T_DOWN, edgeStyle); + } else { + // the heads of the branches are on this row too: an arrow joins the lines already there + for (int c = minC; c <= maxC; c++) { + if (c != toCx) { + joinChar(buffer, area, midRow, c, hChar, edgeStyle); + } + } + joinChar(buffer, area, midRow, fromCx, T_UP, edgeStyle); + joinChar(buffer, area, midRow, toCx, toCx > fromCx ? TR : TL, edgeStyle); } - arrowChar(buffer, area, midRow, fromCx, T_UP, edgeStyle); - arrowChar(buffer, area, midRow, toCx, T_DOWN, edgeStyle); - for (int r = midRow + 1; r < toRow - 1; r++) { arrowChar(buffer, area, r, toCx, vChar, edgeStyle); } - arrowChar(buffer, area, toRow - 1, toCx, ARROW, edgeStyle); + if (density.boxed()) { + arrowChar(buffer, area, toRow - 1, toCx, ARROW, edgeStyle); + } else if (toRow - 1 > midRow) { + arrowChar(buffer, area, toRow - 1, toCx, vChar, edgeStyle); + } + } + } + + /** + * Draws a line character where the lines of other arrows may be: a junction, a corner turned into a junction, a + * head and a count stay. + */ + private void joinChar(Buffer buffer, Rect area, int row, int col, char ch, Style style) { + int x = area.x() + col - scrollX; + int y = area.y() + row - scrollY; + if (x < area.left() || x >= area.right() || y < area.top() || y >= area.bottom()) { + return; } + String existing = buffer.get(x, y).symbol(); + char e = existing.length() == 1 ? existing.charAt(0) : ' '; + char joined = ch; + if (Character.isDigit(e) || e == ARROW || e == CROSS) { + joined = e; + } else if (ch == H || ch == DASH_H) { + joined = e == TL || e == TR ? T_DOWN : e == T_DOWN || e == T_UP ? e : ch; + } else if (ch == V || ch == DASH_V) { + joined = e == H || e == DASH_H || e == T_UP || e == T_DOWN ? CROSS : ch; + } else if (ch == T_UP) { + joined = e == T_DOWN || e == V || e == DASH_V ? CROSS : T_UP; + } else if (ch == TL || ch == TR) { + joined = e == H || e == DASH_H || e == T_DOWN || (e == TL || e == TR) && e != ch ? T_DOWN + : e == T_UP ? CROSS : ch; + } + arrowChar(buffer, area, row, col, joined, style); } private void arrowChar(Buffer buffer, Rect area, int row, int col, char ch, Style style) { @@ -664,6 +722,11 @@ public class RouteDiagramWidget implements Widget { int row1 = toRow(bounds.minY - SCOPE_BOX_PAD); int col2 = toCol(bounds.maxX + SCOPE_BOX_PAD); int row2 = toRow(bounds.maxY + SCOPE_BOX_PAD); + if (density == RouteDensity.COMPACT) { + // a row above the block and a row below it + row1 = toRow(scopeNode.y) - 1; + row2 = toRow(density.blockBottom(tn)); + } Color scopeColor = getEipColor(scopeNode.type); Style scopeStyle = Style.EMPTY.fg(scopeColor).dim(); @@ -690,13 +753,17 @@ public class RouteDiagramWidget implements Widget { } private int getTopRow(LayoutNode node) { - if (node.treeNode != null && RouteDiagramLayoutEngine.hasScope(node.treeNode)) { - return toRow(node.y - SCOPE_BOX_PAD); + if (density.boxed() && node.treeNode != null && RouteDiagramLayoutEngine.hasScope(node.treeNode)) { + return density == RouteDensity.COMPACT ? toRow(node.y) - 1 : toRow(node.y - SCOPE_BOX_PAD); } return toRow(node.y); } private int centerCol(LayoutNode node) { + if (!density.boxed() && node.width == 0) { + // a bare step: its mark, at the left + return toCol(node.x) + 1; + } return toCol(node.x + (node.width > 0 ? node.width : nodeWidth) / 2); } @@ -704,7 +771,11 @@ public class RouteDiagramWidget implements Widget { if (node.treeNode != null && node.treeNode.table) { return 2 + tableVisibleRows(node.treeNode.children.size()); } - return 2 + rewrapText(node, boxWidth - 4).size(); + return switch (density) { + case BARE -> 1; + case COMPACT -> 3; + default -> 2 + rewrapText(node, boxWidth - 4).size(); + }; } /** The rows a table shows: its cases up to the limit, and a line that says there are more. */ @@ -871,6 +942,11 @@ public class RouteDiagramWidget implements Widget { } private List<String> rewrapText(LayoutNode node, int maxWidth) { + return wrapText(labelText(node), maxWidth); + } + + /** The text of a step on one line: its code, or in the business view its description. */ + private String labelText(LayoutNode node) { String label = String.join("", node.wrappedLines); if (showDescription) { if ("from".equals(node.type) && currentRouteLabel != null) { @@ -885,7 +961,89 @@ public class RouteDiagramWidget implements Widget { } } } - return wrapText(label, maxWidth); + return label; + } + + /** The route a link marker names: by id, or a from box's caller in words in the business view. */ + private String linkName(LayoutNode node, String linkedRouteId) { + if (showDescription && "from".equals(node.type)) { + // a from box names its caller in words; a to box already shows the route it links to in words, so + // its marker names the route by id, as the breadcrumb does + String desc = routeDescriptions.get(linkedRouteId); + if (desc != null && !desc.isBlank()) { + return desc; + } + } + return linkedRouteId; + } + + private void addFoldedMarker(LayoutNode node, int row, int col) { + Integer steps = node.id != null ? foldedSteps.get(node.id) : null; + if (steps != null) { + markers.add(new Marker( + row, col, " ▸ " + steps + (steps == 1 ? " step" : " steps") + " folded", + Theme.muted())); + } + } + + /** A text with its EIP (up to its first bracket) in the EIP's colour, and the rest as a label. */ + private void writeLabel(Buffer buffer, Rect area, int row, int col, String text, Color eipColor, boolean selected) { + int open = text.indexOf('['); + String eip = open > 0 ? text.substring(0, open) : text; + writeText(buffer, area, row, col, eip, style(Style.EMPTY.fg(eipColor).bold(), selected)); + if (open > 0) { + writeText(buffer, area, row, col + CharWidth.of(eip), text.substring(open), style(fromLabelStyle(), selected)); + } + } + + /** + * A step at the bare density: a mark of its kind and its text on one row, with no box; the arrows meet at the mark. + * Its text goes on up to half way to the next branch. + */ + private void drawBareNode(Buffer buffer, Rect area, LayoutNode node) { + int col = toCol(node.x); + int row = toRow(node.y); + boolean selected = nodeBoxes.size() == selectedNodeIndex; + boolean external = isExternalEndpoint(node); + boolean highlighted = node.id != null && highlightNodeIds.contains(node.id); + Color eipColor; + if (highlighted) { + eipColor = highlightFailed ? highlightFailColor() : highlightOkColor(); + } else { + eipColor = external ? externalColor() : getEipColor(node.type); + } + String text = DiagramText.fit(labelText(node), boxWidth + boxWidth / 2 - 4); + writeText(buffer, area, row, col + 1, bareMark(node), style(Style.EMPTY.fg(eipColor).bold(), selected)); + if (selected) { + setChar(buffer, area, row, col + 2, ' ', selectionStyle()); + } + writeLabel(buffer, area, row, col + 3, text, eipColor, selected); + int end = col + 3 + CharWidth.of(text); + + String linkedRouteId = findLinkedRouteId(node); + if (linkedRouteId != null) { + markers.add(new Marker(row, end, " ↵ " + linkName(node, linkedRouteId), Theme.label().bold())); + end += 3 + CharWidth.of(linkName(node, linkedRouteId)); + } + addFoldedMarker(node, row, end); + nodeBoxes.add(new EipNodeBox(node.id, node.type, row, row, col, col + 2 + CharWidth.of(text), node)); + } + + /** The mark of a bare step: where the route starts, a step that branches or has steps in it, a branch, a step. */ + private String bareMark(LayoutNode node) { + if ("from".equals(node.type)) { + return "▶"; + } + if (node.id != null && foldedSteps.containsKey(node.id)) { + return "⊞"; + } + if (node.treeNode != null && BRANCH_CHILD_TYPES.contains(node.type)) { + return "◇"; + } + if (node.treeNode != null && !node.treeNode.children.isEmpty()) { + return "◆"; + } + return "●"; } static List<String> wrapText(String text, int maxWidth) { diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/diagram.md b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/diagram.md index ecce7337aafb..450d0137a208 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/diagram.md +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/diagram.md @@ -91,7 +91,8 @@ say: no AI labels, notes or capabilities; it does not change the AI overview setting; with no summary yet it has the AI write one, as `/overview` does), `b` view (business: labels and what capabilities achieve; technical: route ids and endpoints), `m` metrics, `e` external, `y` types -(the body types on the arrows of a route), `d` detail, `g` group, `u` utility (one setting for the architecture +(the body types on the arrows of a route), `+/-` zoom (how much room a step of a route takes; +fit when `z` chose it for the screen), `d` detail, `g` group, `u` utility (one setting for the architecture and the topology, so both show the same groups), `x` errors (the error handling in a frame below the routes; in the architecture a box of the routes reached only on error). The footer lists @@ -240,6 +241,15 @@ you an at-a-glance view of where you are in the route. - `y` — show the body types each step has been reached with, beside the arrows into the steps - `g` — go to node (fuzzy search popup) +- `+` / `-` — zoom in or out: detailed (boxes with up to three lines), + compact (boxes with one line, the steps closer together) and bare (a + mark and the text of each step on one row, no boxes); the selected + step stays where it is on the screen +- `z` — fit: the most detail in which the whole route fits the screen +- `Space` — fold the block of the selected step (a choice, a branch, + a split, a doTry...) into its first step, which says how many steps + are folded; `Space` on a folded step unfolds it +- `f` — fold the outermost blocks of the route, or unfold all - `Esc` — go back (previous route or topology) - `t` — jump back to topology view diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/routes.md b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/routes.md index e35f9ce0101a..073e5346e231 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/routes.md +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/routes.md @@ -162,6 +162,15 @@ in the diagram. - `Enter` — jump to linked route (when `↵` indicator shown) - `d` — toggle detail panel (EIP/component catalog docs) - `g` — go to node (fuzzy search popup) +- `+` / `-` — zoom in or out: detailed (boxes with up to three lines), + compact (boxes with one line, the steps closer together) and bare (a + mark and the text of each step on one row, no boxes); the selected + step stays where it is on the screen +- `z` — fit: the most detail in which the whole route fits the screen +- `Space` — fold the block of the selected step (a choice, a branch, + a split, a doTry...) into its first step, which says how many steps + are folded; `Space` on a folded step unfolds it +- `f` — fold the outermost blocks of the route, or unfold all - `c` — show source code at selected node - `Esc` — go back (previous route or topology) - `t` — jump back to topology view diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/RouteDiagramZoomTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/RouteDiagramZoomTest.java new file mode 100644 index 000000000000..35e42b1daf7a --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/RouteDiagramZoomTest.java @@ -0,0 +1,178 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.dsl.jbang.core.commands.tui; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.apache.camel.diagram.RouteDiagramLayoutEngine.LayoutNode; +import org.apache.camel.diagram.RouteDiagramLayoutEngine.LayoutRoute; +import org.apache.camel.diagram.RouteDiagramLayoutEngine.NodeInfo; +import org.apache.camel.diagram.RouteDiagramLayoutEngine.NodeLabelMode; +import org.apache.camel.diagram.RouteDiagramLayoutEngine.RouteInfo; +import org.apache.camel.dsl.jbang.core.commands.tui.diagram.RouteDensity; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class RouteDiagramZoomTest { + + private static NodeInfo node(String type, String id, int level) { + NodeInfo n = new NodeInfo(); + n.type = type; + n.id = id; + n.level = level; + n.code = type + "[" + id + "]"; + return n; + } + + private static RouteInfo route() { + RouteInfo r = new RouteInfo(); + r.routeId = "orders"; + r.nodes.add(node("route", "orders", 0)); + r.nodes.add(node("from", "from1", 1)); + r.nodes.add(node("choice", "choice1", 2)); + r.nodes.add(node("when", "when1", 3)); + r.nodes.add(node("to", "to1", 4)); + r.nodes.add(node("log", "log1", 4)); + r.nodes.add(node("otherwise", "otherwise1", 3)); + r.nodes.add(node("to", "to2", 4)); + r.nodes.add(node("split", "split1", 2)); + r.nodes.add(node("to", "to3", 3)); + r.nodes.add(node("to", "to4", 2)); + return r; + } + + private static LayoutRoute layout(RouteDiagramZoom zoom, int visibleRows) { + return zoom.layoutRoutes(List.of(route()), NodeLabelMode.CODE, false, List.of(), new HashMap<>(), visibleRows) + .get("orders"); + } + + private static LayoutNode find(LayoutRoute lr, String id) { + return lr.nodes.stream().filter(n -> id.equals(n.id)).findFirst().orElseThrow(); + } + + private static List<String> ids(LayoutRoute lr) { + return lr.nodes.stream().map(n -> n.id).toList(); + } + + @Test + void spaceOnAStepFoldsTheBlockItIsIn() { + RouteDiagramZoom zoom = new RouteDiagramZoom(); + LayoutRoute lr = layout(zoom, 0); + + assertThat(zoom.toggleFold("orders", find(lr, "log1"))).isEqualTo("when1"); + lr = layout(zoom, 0); + assertThat(ids(lr)).contains("when1", "otherwise1", "to2").doesNotContain("to1", "log1"); + assertThat(zoom.foldedStepsOf(lr)).isEqualTo(Map.of("when1", 2)); + + // the choice folds its branches, and the branch folded in it stays folded when the choice opens again + assertThat(zoom.toggleFold("orders", find(lr, "choice1"))).isEqualTo("choice1"); + lr = layout(zoom, 0); + assertThat(ids(lr)).contains("choice1", "split1", "to4").doesNotContain("when1", "to2"); + assertThat(zoom.foldedStepsOf(lr)).isEqualTo(Map.of("choice1", 5)); + zoom.toggleFold("orders", find(lr, "choice1")); + assertThat(zoom.foldedStepsOf(layout(zoom, 0))).isEqualTo(Map.of("when1", 2)); + } + + @Test + void aStepOutsideAnyBlockHasNothingToFold() { + RouteDiagramZoom zoom = new RouteDiagramZoom(); + LayoutRoute lr = layout(zoom, 0); + assertThat(zoom.toggleFold("orders", find(lr, "to4"))).isNull(); + assertThat(zoom.toggleFold("orders", find(lr, "from1"))).isNull(); + } + + @Test + void foldAllFoldsTheOutermostBlocksThenUnfoldsAll() { + RouteDiagramZoom zoom = new RouteDiagramZoom(); + zoom.toggleFoldAll("orders", layout(zoom, 0)); + LayoutRoute lr = layout(zoom, 0); + assertThat(zoom.foldedStepsOf(lr)).isEqualTo(Map.of("choice1", 5, "split1", 1)); + + zoom.toggleFoldAll("orders", lr); + assertThat(zoom.foldedStepsOf(layout(zoom, 0))).isEmpty(); + } + + @Test + void zoomOutAndFit() { + RouteDiagramZoom zoom = new RouteDiagramZoom(); + int detailed = RouteDensity.rows(layout(zoom, 0)); + zoom.zoomOut(); + LayoutRoute compact = layout(zoom, 0); + assertThat(zoom.densityOf(compact)).isEqualTo(RouteDensity.COMPACT); + assertThat(RouteDensity.rows(compact)).isLessThan(detailed); + zoom.zoomOut(); + zoom.zoomOut(); + assertThat(zoom.density()).isEqualTo(RouteDensity.BARE); + int bare = RouteDensity.rows(layout(zoom, 0)); + + // fit: the most detail that fits the rows + zoom.toggleFit(); + assertThat(zoom.densityOf(layout(zoom, detailed + 5))).isEqualTo(RouteDensity.DETAILED); + assertThat(zoom.densityOf(layout(zoom, RouteDensity.rows(compact)))).isEqualTo(RouteDensity.COMPACT); + LayoutRoute fitted = layout(zoom, bare); + assertThat(zoom.densityOf(fitted)).isEqualTo(RouteDensity.BARE); + // the route starts at the top, as without fit + assertThat(find(fitted, "from1").y).isEqualTo(40); + assertThat(zoom.label(layout(zoom, bare))).isEqualTo("fit bare"); + // a zoom key leaves fit + zoom.zoomIn(); + assertThat(zoom.isFit()).isFalse(); + assertThat(zoom.density()).isEqualTo(RouteDensity.COMPACT); + } + + @Test + void theSelectedStepIsSelectedAgainInTheNewLayout() { + RouteDiagramZoom zoom = new RouteDiagramZoom(); + LayoutRoute before = layout(zoom, 0); + zoom.anchor(before, "split1", 7); + // not the layout it was anchored on yet + assertThat(zoom.restore(before)).isNull(); + + zoom.zoomOut(); + LayoutRoute after = layout(zoom, 0); + RouteDiagramZoom.Restore restore = zoom.restore(after); + assertThat(restore).isNotNull(); + // the boxes: from, choice, when, to1, log1, otherwise, to2, split + assertThat(restore.index()).isEqualTo(7); + assertThat(restore.row()).isEqualTo(find(after, "split1").y / RouteDensity.ROW); + assertThat(restore.screenRow()).isEqualTo(7); + // once + assertThat(zoom.restore(after)).isNull(); + } + + @Test + void theDensityOrFitIsKeptAsASetting() { + RouteDiagramZoom zoom = new RouteDiagramZoom(); + assertThat(zoom.setting()).isNull(); + zoom.zoomOut(); + assertThat(zoom.setting()).isEqualTo("compact"); + zoom.toggleFit(); + assertThat(zoom.setting()).isEqualTo("fit"); + + RouteDiagramZoom again = new RouteDiagramZoom(); + again.setting("bare"); + assertThat(again.density()).isEqualTo(RouteDensity.BARE); + again.setting("fit"); + assertThat(again.isFit()).isTrue(); + again.setting("nonsense"); + assertThat(again.density()).isEqualTo(RouteDensity.DETAILED); + assertThat(again.isFit()).isFalse(); + } +} diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/RouteDensityTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/RouteDensityTest.java new file mode 100644 index 000000000000..8d5d48454cf3 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/RouteDensityTest.java @@ -0,0 +1,180 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.dsl.jbang.core.commands.tui.diagram; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import dev.tamboui.buffer.Buffer; +import dev.tamboui.layout.Rect; +import org.apache.camel.diagram.RouteDiagramLayoutEngine; +import org.apache.camel.diagram.RouteDiagramLayoutEngine.LayoutRoute; +import org.apache.camel.diagram.RouteDiagramLayoutEngine.NodeInfo; +import org.apache.camel.diagram.RouteDiagramLayoutEngine.RouteInfo; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The densities of the route diagram: the compact and the bare ones put the same steps on fewer rows. + */ +class RouteDensityTest { + + static NodeInfo node(String type, String id, int level, String code) { + NodeInfo n = new NodeInfo(); + n.type = type; + n.id = id; + n.level = level; + n.code = code; + return n; + } + + static RouteInfo route() { + RouteInfo r = new RouteInfo(); + r.routeId = "orders"; + r.nodes.add(node("route", "orders", 0, "route[orders]")); + r.nodes.add(node("from", "from1", 1, "from[kafka:orders]")); + r.nodes.add(node("log", "log1", 2, "log[Received ${body}]")); + r.nodes.add(node("choice", "choice1", 2, "choice")); + r.nodes.add(node("when", "when1", 3, "when[${header.priority} == 'high']")); + r.nodes.add(node("to", "to1", 4, "to[jms:queue:priority]")); + r.nodes.add(node("log", "log2", 4, "log[Priority order]")); + r.nodes.add(node("otherwise", "otherwise1", 3, "otherwise")); + r.nodes.add(node("to", "to2", 4, "to[jms:queue:normal]")); + r.nodes.add(node("split", "split1", 2, "split[${body.items}]")); + r.nodes.add(node("to", "to3", 3, "to[http:inventory/reserve]")); + r.nodes.add(node("log", "log3", 3, "log[Reserved ${body}]")); + r.nodes.add(node("to", "to4", 2, "to[kafka:orders-done]")); + return r; + } + + static List<String> render(LayoutRoute lr, int nodeWidth, RouteDensity density, Map<String, Integer> folded) { + RouteDiagramWidget widget = new RouteDiagramWidget( + lr, nodeWidth, -1, 0, 0, false, Map.of(), false, Map.of(), Set.of(), false) + .withDensity(density).withFoldedSteps(folded); + Rect area = new Rect(0, 0, 160, 120); + Buffer buffer = Buffer.empty(area); + widget.render(area, buffer); + List<String> rows = new ArrayList<>(); + for (int y = 0; y < area.height(); y++) { + StringBuilder sb = new StringBuilder(); + for (int x = 0; x < area.width(); x++) { + sb.append(buffer.get(x, y).symbol()); + } + rows.add(sb.toString().stripTrailing()); + } + while (!rows.isEmpty() && rows.get(rows.size() - 1).isEmpty()) { + rows.remove(rows.size() - 1); + } + return rows; + } + + private static List<String> render(RouteDensity density) { + RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine(); + engine.setTableLayout(true); + LayoutRoute lr = engine.layoutRoute(route(), 0); + density.apply(lr); + List<String> rows = render(lr, engine.getNodeWidth(), density, Map.of()); + if (Boolean.getBoolean("routeDensity.print")) { + System.out.println("---- " + density + " (" + rows.size() + " rows)"); + rows.forEach(System.out::println); + } + return rows; + } + + @Test + void theStepsTakeFewerRowsAsTheDiagramZoomsOut() { + List<String> detailed = render(RouteDensity.DETAILED); + List<String> compact = render(RouteDensity.COMPACT); + List<String> bare = render(RouteDensity.BARE); + + assertThat(compact.size()).isLessThan(detailed.size() * 3 / 4); + assertThat(bare.size()).isLessThan(compact.size() * 3 / 4); + // every step is there at each density + for (List<String> rows : List.of(detailed, compact, bare)) { + String all = String.join("\n", rows); + assertThat(all).contains("from[kafka:orders]").contains("choice").contains("otherwise").contains("split"); + assertThat(all.split("to\\[", -1)).hasSize(5); + } + } + + @Test + void aCompactStepIsABoxWithOneLine() { + String all = String.join("\n", render(RouteDensity.COMPACT)); + assertThat(all).contains("┌").contains("▼"); + // the code of a step is not wrapped onto a second line + assertThat(all).doesNotContain("priority] "); + } + + @Test + void aBareStepIsAMarkAndItsText() { + List<String> rows = render(RouteDensity.BARE); + String all = String.join("\n", rows); + assertThat(all).doesNotContain("└").doesNotContain("▼").doesNotContain("╌"); + assertThat(rows).anyMatch(r -> r.contains("▶ from[kafka:orders]")); + assertThat(rows).anyMatch(r -> r.contains("◆ choice")); + assertThat(rows).anyMatch(r -> r.contains("◇ when")); + assertThat(rows).anyMatch(r -> r.contains("● to[kafka:orders-done]")); + } + + @Test + void aStepTheBlockIsFoldedIntoSaysHowManyStepsAreFolded() { + RouteInfo r = route(); + // the choice and its branches left out, as the zoom does with a folded block + r.nodes.removeIf(n -> n.level > 2 && List.of("when1", "to1", "log2", "otherwise1", "to2").contains(n.id)); + RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine(); + engine.setTableLayout(true); + LayoutRoute lr = engine.layoutRoute(r, 0); + String all = String.join("\n", render(lr, engine.getNodeWidth(), RouteDensity.DETAILED, Map.of("choice1", 5))); + assertThat(all).contains("▸ 5 steps folded").doesNotContain("jms:queue:priority"); + } + + private static List<String> renderMulticast(RouteDensity density) { + RouteInfo r = new RouteInfo(); + r.routeId = "fanout"; + r.nodes.add(node("route", "fanout", 0, "route[fanout]")); + r.nodes.add(node("from", "from1", 1, "from[direct:in]")); + r.nodes.add(node("multicast", "multicast1", 2, "multicast")); + for (int i = 1; i <= 4; i++) { + r.nodes.add(node("to", "to" + i, 3, "to[direct:out" + i + "]")); + } + RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine(); + LayoutRoute lr = engine.layoutRoute(r, 0); + density.apply(lr); + return render(lr, engine.getNodeWidth(), density, Map.of()); + } + + private static long count(String row, char c) { + return row.chars().filter(ch -> ch == c).count(); + } + + @Test + void theArrowsOfManyBranchesJoinOnTheirRow() { + // bare: one line from the first branch to the last, a junction at each branch between + String bare = renderMulticast(RouteDensity.BARE).stream().filter(row -> row.contains("┴")).findFirst() + .orElseThrow(); + assertThat(count(bare, '┌')).isEqualTo(1); + assertThat(count(bare, '┐')).isEqualTo(1); + assertThat(count(bare, '┬')).isEqualTo(2); + // compact: the heads of all the branches are on the row of the line + String compact = renderMulticast(RouteDensity.COMPACT).stream().filter(row -> row.contains("┴")).findFirst() + .orElseThrow(); + assertThat(count(compact, '▼')).isEqualTo(4); + } +}
