This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch camel-23485-ascii-art-renderer in repository https://gitbox.apache.org/repos/asf/camel.git
commit 7e93029a3e5b7834f8ddb705e9f064c421284ca9 Author: Claus Ibsen <[email protected]> AuthorDate: Tue May 12 14:44:56 2026 +0200 CAMEL-23485: camel-diagram - Add --theme=unicode with box-drawing characters Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> --- .../camel-diagram/src/main/docs/diagram.adoc | 33 +++++++ .../camel/diagram/RouteDiagramAsciiRenderer.java | 102 ++++++++++++++++----- .../org/apache/camel/diagram/RouteDiagramTest.java | 63 +++++++++++++ .../camel-jbang-cmd-route-diagram.adoc | 2 +- .../META-INF/camel-jbang-commands-metadata.json | 2 +- .../commands/action/CamelRouteDiagramAction.java | 25 +++-- 6 files changed, 191 insertions(+), 36 deletions(-) diff --git a/components/camel-diagram/src/main/docs/diagram.adoc b/components/camel-diagram/src/main/docs/diagram.adoc index c9525206ede9..8792f2073f4d 100644 --- a/components/camel-diagram/src/main/docs/diagram.adoc +++ b/components/camel-diagram/src/main/docs/diagram.adoc @@ -183,3 +183,36 @@ route1 Scope boxes (for filter, split, doTry, etc.) are rendered with dashed borders using `:` for vertical and `- - -` for horizontal lines. + +Use `--theme=ascii` for plain ASCII art: + +[source,bash] +---- +camel cmd route-diagram MyRoute.java --theme=ascii +---- + +== Unicode Rendering + +The `unicode` theme uses Unicode box-drawing characters for a cleaner look. +Node boxes use `┌──┐ │ └──┘`, arrows use `│` and `▼`, and branch junctions use `┴`. +Scope boxes use `╌` (dashed horizontal) and `╎` (dashed vertical) with no corners. + +[source,bash] +---- +camel cmd route-diagram MyRoute.java --theme=unicode +---- + +Example output: + +---- +route1 +┌──────────────────────┐ +│ timer:tick │ +└──────────────────────┘ + │ + │ + ▼ +┌──────────────────────┐ +│ log:a │ +└──────────────────────┘ +---- diff --git a/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramAsciiRenderer.java b/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramAsciiRenderer.java index 86c4c6ba1226..1b9ef494b820 100644 --- a/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramAsciiRenderer.java +++ b/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramAsciiRenderer.java @@ -29,7 +29,7 @@ import static org.apache.camel.diagram.RouteDiagramLayoutEngine.PADDING; import static org.apache.camel.diagram.RouteDiagramLayoutEngine.SCOPE_BOX_PAD; /** - * Renders route diagrams as plain ASCII art text. + * Renders route diagrams as plain ASCII art or Unicode box-drawing text. */ public class RouteDiagramAsciiRenderer { @@ -38,12 +38,34 @@ public class RouteDiagramAsciiRenderer { private static final int MIN_BOX_WIDTH = 16; private static final int X_DIVISOR = 15; + // Unicode box-drawing characters + private static final char UNI_H = '─'; // ─ + private static final char UNI_V = '│'; // │ + private static final char UNI_TL = '┌'; // ┌ + private static final char UNI_TR = '┐'; // ┐ + private static final char UNI_BL = '└'; // └ + private static final char UNI_BR = '┘'; // ┘ + private static final char UNI_T_DOWN = '┬'; // ┬ + private static final char UNI_T_UP = '┴'; // ┴ + private static final char UNI_T_RIGHT = '├'; // ├ + private static final char UNI_T_LEFT = '┤'; // ┤ + private static final char UNI_CROSS = '┼'; // ┼ + private static final char UNI_ARROW = '▼'; // ▼ + private static final char UNI_DASH_H = '╌'; // ╌ + private static final char UNI_DASH_V = '╎'; // ╎ + private final int nodeWidth; private final int boxWidth; + private final boolean unicode; public RouteDiagramAsciiRenderer(int nodeWidth) { + this(nodeWidth, false); + } + + public RouteDiagramAsciiRenderer(int nodeWidth, boolean unicode) { this.nodeWidth = nodeWidth; this.boxWidth = Math.max(MIN_BOX_WIDTH, nodeWidth / X_DIVISOR); + this.unicode = unicode; } public int getBoxWidth() { @@ -108,23 +130,26 @@ public class RouteDiagramAsciiRenderer { return; } - setChar(grid, row, col, '+'); + char h = unicode ? UNI_H : '-'; + char v = unicode ? UNI_V : '|'; + + setChar(grid, row, col, unicode ? UNI_TL : '+'); for (int c = col + 1; c < col + boxWidth - 1; c++) { - setChar(grid, row, c, '-'); + setChar(grid, row, c, h); } - setChar(grid, row, col + boxWidth - 1, '+'); + setChar(grid, row, col + boxWidth - 1, unicode ? UNI_TR : '+'); int bottom = row + height - 1; - setChar(grid, bottom, col, '+'); + setChar(grid, bottom, col, unicode ? UNI_BL : '+'); for (int c = col + 1; c < col + boxWidth - 1; c++) { - setChar(grid, bottom, c, '-'); + setChar(grid, bottom, c, h); } - setChar(grid, bottom, col + boxWidth - 1, '+'); + setChar(grid, bottom, col + boxWidth - 1, unicode ? UNI_BR : '+'); for (int i = 0; i < lines.size(); i++) { int r = row + 1 + i; - setChar(grid, r, col, '|'); - setChar(grid, r, col + boxWidth - 1, '|'); + setChar(grid, r, col, v); + setChar(grid, r, col + boxWidth - 1, v); for (int c = col + 1; c < col + boxWidth - 1; c++) { setChar(grid, r, c, ' '); } @@ -160,30 +185,40 @@ public class RouteDiagramAsciiRenderer { return; } + char v = unicode ? UNI_V : '|'; + char h = unicode ? UNI_H : '-'; + char arrow = unicode ? UNI_ARROW : 'v'; + if (fromCx == toCx) { for (int r = fromRow; r < toRow - 1; r++) { - plotLine(grid, r, fromCx, '|'); + plotLine(grid, r, fromCx, v); } - setChar(grid, toRow - 1, toCx, 'v'); + setChar(grid, toRow - 1, toCx, arrow); } else { int midRow = fromRow + (toRow - fromRow) / 2; for (int r = fromRow; r < midRow; r++) { - plotLine(grid, r, fromCx, '|'); + plotLine(grid, r, fromCx, v); } int minC = Math.min(fromCx, toCx); int maxC = Math.max(fromCx, toCx); for (int c = minC; c <= maxC; c++) { - plotLine(grid, midRow, c, '-'); + plotLine(grid, midRow, c, h); + } + + if (unicode) { + setChar(grid, midRow, fromCx, UNI_T_UP); + setChar(grid, midRow, toCx, UNI_T_DOWN); + } else { + setChar(grid, midRow, fromCx, '+'); + setChar(grid, midRow, toCx, '+'); } - setChar(grid, midRow, fromCx, '+'); - setChar(grid, midRow, toCx, '+'); for (int r = midRow + 1; r < toRow - 1; r++) { - plotLine(grid, r, toCx, '|'); + plotLine(grid, r, toCx, v); } - setChar(grid, toRow - 1, toCx, 'v'); + setChar(grid, toRow - 1, toCx, arrow); } } @@ -201,11 +236,22 @@ public class RouteDiagramAsciiRenderer { int col2 = toCol(bounds.maxX + SCOPE_BOX_PAD); int row2 = toRow(bounds.maxY + SCOPE_BOX_PAD); - drawDashedHLine(grid, row1, col1, col2); - drawDashedHLine(grid, row2, col1, col2); - for (int r = row1 + 1; r < row2; r++) { - setChar(grid, r, col1, ':'); - setChar(grid, r, col2, ':'); + if (unicode) { + for (int c = col1; c <= col2; c++) { + setChar(grid, row1, c, UNI_DASH_H); + setChar(grid, row2, c, UNI_DASH_H); + } + for (int r = row1 + 1; r < row2; r++) { + setChar(grid, r, col1, UNI_DASH_V); + setChar(grid, r, col2, UNI_DASH_V); + } + } else { + drawDashedHLine(grid, row1, col1, col2); + drawDashedHLine(grid, row2, col1, col2); + for (int r = row1 + 1; r < row2; r++) { + setChar(grid, r, col1, ':'); + setChar(grid, r, col2, ':'); + } } } @@ -307,10 +353,18 @@ public class RouteDiagramAsciiRenderer { return ' '; } + private boolean isVertical(char ch) { + return ch == '|' || ch == UNI_V; + } + + private boolean isHorizontal(char ch) { + return ch == '-' || ch == UNI_H; + } + private void plotLine(char[][] grid, int row, int col, char ch) { char current = getChar(grid, row, col); - if ((current == '|' && ch == '-') || (current == '-' && ch == '|')) { - setChar(grid, row, col, '+'); + if (isVertical(current) && isHorizontal(ch) || isHorizontal(current) && isVertical(ch)) { + setChar(grid, row, col, unicode ? UNI_CROSS : '+'); } else { setChar(grid, row, col, ch); } diff --git a/components/camel-diagram/src/test/java/org/apache/camel/diagram/RouteDiagramTest.java b/components/camel-diagram/src/test/java/org/apache/camel/diagram/RouteDiagramTest.java index 7024198eef7d..b6ab0d9fd7d8 100644 --- a/components/camel-diagram/src/test/java/org/apache/camel/diagram/RouteDiagramTest.java +++ b/components/camel-diagram/src/test/java/org/apache/camel/diagram/RouteDiagramTest.java @@ -971,6 +971,69 @@ class RouteDiagramTest { assertTrue(result.contains("+ -"), "Scope box should have dashed horizontal borders"); } + @Test + void testUnicodeDiagramSequential() { + RouteInfo route = new RouteInfo(); + route.routeId = "route1"; + route.nodes.add(node("from", "timer:tick", 0)); + route.nodes.add(node("to", "log:a", 1)); + + RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine(); + LayoutRoute lr = engine.layoutRoute(route, RouteDiagramLayoutEngine.PADDING); + + RouteDiagramAsciiRenderer renderer = new RouteDiagramAsciiRenderer(engine.getNodeWidth(), true); + String result = renderer.renderDiagram(List.of(lr), lr.maxY + RouteDiagramLayoutEngine.V_GAP); + + assertTrue(result.contains("timer:tick")); + assertTrue(result.contains("log:a")); + assertTrue(result.contains("┌"), "Should contain Unicode top-left corner"); + assertTrue(result.contains("─"), "Should contain Unicode horizontal line"); + assertTrue(result.contains("│"), "Should contain Unicode vertical line"); + assertTrue(result.contains("▼"), "Should contain Unicode arrow head"); + } + + @Test + void testUnicodeDiagramBranching() { + RouteInfo route = new RouteInfo(); + route.routeId = "route1"; + route.nodes.add(node("from", "timer:tick", 0)); + route.nodes.add(node("choice", "choice()", 1)); + route.nodes.add(node("when", "when(...)", 2)); + route.nodes.add(node("to", "log:a", 3)); + route.nodes.add(node("otherwise", "otherwise()", 2)); + route.nodes.add(node("to", "log:b", 3)); + + RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine(); + LayoutRoute lr = engine.layoutRoute(route, RouteDiagramLayoutEngine.PADDING); + + RouteDiagramAsciiRenderer renderer = new RouteDiagramAsciiRenderer(engine.getNodeWidth(), true); + String result = renderer.renderDiagram(List.of(lr), lr.maxY + RouteDiagramLayoutEngine.V_GAP); + + assertTrue(result.contains("choice()")); + assertTrue(result.contains("when(...)")); + assertTrue(result.contains("┴"), "Should contain Unicode T-up junction for branch split"); + } + + @Test + void testUnicodeDiagramWithScopeBox() { + RouteInfo route = new RouteInfo(); + route.routeId = "route1"; + route.nodes.add(node("from", "direct:start", 0)); + route.nodes.add(node("filter", "filter[header(x)]", 1)); + route.nodes.add(node("log", "log[filtered]", 2)); + route.nodes.add(node("to", "to[mock:end]", 1)); + + RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine(); + LayoutRoute lr = engine.layoutRoute(route, RouteDiagramLayoutEngine.PADDING); + + RouteDiagramAsciiRenderer renderer = new RouteDiagramAsciiRenderer(engine.getNodeWidth(), true); + String result = renderer.renderDiagram(List.of(lr), lr.maxY + RouteDiagramLayoutEngine.V_GAP); + + assertTrue(result.contains("filter[header(x)]")); + assertTrue(result.contains("╌"), "Scope box should have Unicode dashed horizontal"); + assertTrue(result.contains("╎"), "Scope box should have Unicode dashed vertical"); + } + @Test void testAsciiWrapTextShort() { List<String> lines = RouteDiagramAsciiRenderer.wrapText("timer:tick", 20); diff --git a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd-route-diagram.adoc b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd-route-diagram.adoc index b5cd5154893d..354db16e98f4 100644 --- a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd-route-diagram.adoc +++ b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd-route-diagram.adoc @@ -26,7 +26,7 @@ camel cmd route-diagram [options] | `--metric` | Whether to include live metrics (only possible for running Camel application) | true | boolean | `--node-label` | What text to display in diagram nodes: code, description, or both (default) | both | String | `--output` | Save diagram to a file (PNG for image themes, text for ascii theme) | | String -| `--theme` | Color theme preset (dark, light, transparent, ascii) or custom colors (e.g. bg=#1e1e1e:from=#2e7d32:to=#1565c0). Values can be #hex or ANSI color names (e.g. from=seagreen:to=steelblue). Use bg= for transparent. Use ascii for plain text output. Can also be set via DIAGRAM_COLORS env var. | transparent | String +| `--theme` | Color theme preset (dark, light, transparent, ascii, unicode) or custom colors (e.g. bg=#1e1e1e:from=#2e7d32:to=#1565c0). Values can be #hex or ANSI color names (e.g. from=seagreen:to=steelblue). Use bg= for transparent. Use ascii/unicode for plain text output. Can also be set via DIAGRAM_COLORS env var. | transparent | String | `--watch` | Execute periodically and showing output fullscreen | | boolean | `--width` | Image width in pixels (0 = auto) | 0 | int | `-h,--help` | Display the help and sub-commands | | boolean diff --git a/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json b/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json index bca47153fd15..399406bab646 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json +++ b/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json @@ -2,7 +2,7 @@ "commands": [ { "name": "bind", "fullName": "bind", "description": "DEPRECATED: Bind source and sink Kamelets as a new Camel integration", "deprecated": true, "sourceClass": "org.apache.camel.dsl.jbang.core.commands.bind.Bind", "options": [ { "names": "--error-handler", "description": "Add error handler (none|log|sink:<endpoint>). Sink endpoints are expected in the format [[apigroup\/]version:]kind:[namespace\/]name, plain Camel URIs or Kamelet name.", "javaType": "java.lang.String", "type": "stri [...] { "name": "catalog", "fullName": "catalog", "description": "List artifacts from Camel Catalog", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.catalog.CatalogCommand", "options": [ { "names": "-h,--help", "description": "Display the help and sub-commands", "javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name": "component", "fullName": "catalog component", "description": "List components from the Camel Catalog", "sourceClass": "org.apache.camel.dsl.jbang.co [...] - { "name": "cmd", "fullName": "cmd", "description": "Performs commands in the running Camel integrations, such as start\/stop route, or change logging levels.", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.action.CamelAction", "options": [ { "names": "-h,--help", "description": "Display the help and sub-commands", "javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name": "browse", "fullName": "cmd browse", "description": "Browse pending messages on endpoints [...] + { "name": "cmd", "fullName": "cmd", "description": "Performs commands in the running Camel integrations, such as start\/stop route, or change logging levels.", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.action.CamelAction", "options": [ { "names": "-h,--help", "description": "Display the help and sub-commands", "javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name": "browse", "fullName": "cmd browse", "description": "Browse pending messages on endpoints [...] { "name": "completion", "fullName": "completion", "description": "Generate completion script for bash\/zsh", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.Complete", "options": [ { "names": "-h,--help", "description": "Display the help and sub-commands", "javaType": "boolean", "type": "boolean" } ] }, { "name": "config", "fullName": "config", "description": "Get and set user configuration values", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.config.ConfigCommand", "options": [ { "names": "-h,--help", "description": "Display the help and sub-commands", "javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name": "get", "fullName": "config get", "description": "Display user configuration value", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.config. [...] { "name": "debug", "fullName": "debug", "description": "Debug local Camel integration", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.Debug", "options": [ { "names": "--ago", "description": "Use ago instead of yyyy-MM-dd HH:mm:ss in timestamp.", "javaType": "boolean", "type": "boolean" }, { "names": "--background", "description": "Run in the background", "defaultValue": "false", "javaType": "boolean", "type": "boolean" }, { "names": "--background-wait", "description": "To [...] diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelRouteDiagramAction.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelRouteDiagramAction.java index c14733d80f07..cde562c5235a 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelRouteDiagramAction.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelRouteDiagramAction.java @@ -73,10 +73,10 @@ public class CamelRouteDiagramAction extends ActionWatchCommand { String output; @CommandLine.Option(names = { "--theme" }, - description = "Color theme preset (dark, light, transparent, ascii) or custom colors " + description = "Color theme preset (dark, light, transparent, ascii, unicode) or custom colors " + "(e.g. bg=#1e1e1e:from=#2e7d32:to=#1565c0). Values can be #hex or " + "ANSI color names (e.g. from=seagreen:to=steelblue). " - + "Use bg= for transparent. Use ascii for plain text output. " + + "Use bg= for transparent. Use ascii/unicode for plain text output. " + "Can also be set via DIAGRAM_COLORS env var.", defaultValue = "transparent") String theme; @@ -117,8 +117,8 @@ public class CamelRouteDiagramAction extends ActionWatchCommand { public Integer doCall() throws Exception { System.setProperty("java.awt.headless", "true"); - boolean ascii = isAsciiTheme(); - if (!ascii) { + boolean textMode = isTextTheme(); + if (!textMode) { String colorSpec = System.getenv("DIAGRAM_COLORS"); colors = DiagramColors.parse(colorSpec != null ? colorSpec : theme); } @@ -127,13 +127,13 @@ public class CamelRouteDiagramAction extends ActionWatchCommand { if (output == null) { terminal = TerminalBuilder.builder().system(true).build(); lineReader = LineReaderBuilder.builder().terminal(terminal).build(); - if (!ascii) { + if (!textMode) { terminalGraphics = TerminalGraphicsManager.getBestProtocol(terminal).orElse(null); if (terminalGraphics == null) { printer().println("Terminal does not support graphics protocols (Kitty, iTerm2, or Sixel)."); printer().println( "Try running in a supported terminal: Kitty, iTerm2, WezTerm, Ghostty, or VS Code."); - printer().println("Or use --theme=ascii for plain text output."); + printer().println("Or use --theme=ascii or --theme=unicode for plain text output."); return 1; } } @@ -209,8 +209,9 @@ public class CamelRouteDiagramAction extends ActionWatchCommand { currentY = lr.maxY + RouteDiagramLayoutEngine.V_GAP; } - if (isAsciiTheme()) { - RouteDiagramAsciiRenderer asciiRenderer = new RouteDiagramAsciiRenderer(engine.getNodeWidth()); + if (isTextTheme()) { + RouteDiagramAsciiRenderer asciiRenderer + = new RouteDiagramAsciiRenderer(engine.getNodeWidth(), isUnicodeTheme()); String ascii = asciiRenderer.renderDiagram(layoutRoutes, currentY); if (output != null) { @@ -352,8 +353,12 @@ public class CamelRouteDiagramAction extends ActionWatchCommand { return RouteDiagramHelper.parseRoutes(jo); } - private boolean isAsciiTheme() { - return "ascii".equalsIgnoreCase(theme); + private boolean isTextTheme() { + return "ascii".equalsIgnoreCase(theme) || "unicode".equalsIgnoreCase(theme); + } + + private boolean isUnicodeTheme() { + return "unicode".equalsIgnoreCase(theme); } static NodeLabelMode parseNodeLabelMode(String value) {
