This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git


The following commit(s) were added to refs/heads/main by this push:
     new cc637efc3fda camel-jbang - TUI: Add inline quick doc (i toggle) for 
source viewer
cc637efc3fda is described below

commit cc637efc3fda4ecc0a4d1953c8ad06a22d6cdc69
Author: Claus Ibsen <[email protected]>
AuthorDate: Sat Jul 25 22:27:19 2026 +0200

    camel-jbang - TUI: Add inline quick doc (i toggle) for source viewer
    
    camel-jbang - TUI: Allow i (quick doc) shortcut from diagram drill-down view
    
    camel-jbang - TUI: Improve quick doc with clustered options, Diagram tab 
support, and format persistence
    
    - Fix ProcessorDetailDevConsole creating duplicate from1 entry that 
overwrote component doc with EIP doc
    - Add clustered endpoint/EIP option docs for Java/XML formats where params 
are in the URI
    - Enable i (quick doc) toggle on Diagram tab source viewer
    - Persist quick doc state across format switches (Tab/Shift+Tab)
    - Add Shift+Tab for reverse format cycling in source viewer
    
    Co-Authored-By: Claude Opus 4.6 <[email protected]>
    Signed-off-by: Claus Ibsen <[email protected]>
---
 .../impl/console/ProcessorDetailDevConsole.java    |  28 ++-
 .../camel/cli/connector/LocalCliConnector.java     |  13 +-
 .../dsl/jbang/core/commands/tui/DiagramTab.java    |  59 +++++
 .../dsl/jbang/core/commands/tui/RoutesTab.java     | 276 ++++++++++++++++++++-
 .../dsl/jbang/core/commands/tui/SourceViewer.java  | 188 ++++++++++++--
 5 files changed, 541 insertions(+), 23 deletions(-)

diff --git 
a/core/camel-console/src/main/java/org/apache/camel/impl/console/ProcessorDetailDevConsole.java
 
b/core/camel-console/src/main/java/org/apache/camel/impl/console/ProcessorDetailDevConsole.java
index a334114649e7..ebe2b4a37764 100644
--- 
a/core/camel-console/src/main/java/org/apache/camel/impl/console/ProcessorDetailDevConsole.java
+++ 
b/core/camel-console/src/main/java/org/apache/camel/impl/console/ProcessorDetailDevConsole.java
@@ -148,7 +148,7 @@ public class ProcessorDetailDevConsole extends 
AbstractDevConsole {
         processors.add(fromEntry);
 
         try {
-            String xml = mr.dumpRouteAsXml();
+            String xml = mr.dumpRouteAsXml(false, true, true);
             if (xml != null && !xml.isBlank()) {
                 DocumentBuilderFactory factory = 
DocumentBuilderFactory.newInstance();
                 
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl";, 
true);
@@ -158,6 +158,12 @@ public class ProcessorDetailDevConsole extends 
AbstractDevConsole {
                 Document doc = builder.parse(new InputSource(new 
StringReader(xml)));
                 Element routeElement = doc.getDocumentElement();
 
+                // extract source line number for the from entry from the 
<from> child element
+                NodeList fromNodes = routeElement.getElementsByTagName("from");
+                if (fromNodes.getLength() > 0) {
+                    extractSourceLineNumber((Element) fromNodes.item(0), 
fromEntry);
+                }
+
                 collectProcessors(routeElement, processors);
             }
         } catch (Exception e) {
@@ -184,16 +190,23 @@ public class ProcessorDetailDevConsole extends 
AbstractDevConsole {
             }
 
             String type = elem.getTagName();
+            // skip <from> elements — already handled as the manual fromEntry 
with endpointUri
+            if ("from".equals(type)) {
+                continue;
+            }
             JsonObject entry = new JsonObject();
             entry.put("id", id);
             entry.put("type", type);
 
+            extractSourceLineNumber(elem, entry);
+
             JsonObject opts = new JsonObject();
             NamedNodeMap attrs = elem.getAttributes();
             for (int j = 0; j < attrs.getLength(); j++) {
                 Attr attr = (Attr) attrs.item(j);
                 String name = attr.getName();
-                if (!"id".equals(name) && !"customId".equals(name) && 
!name.startsWith("xmlns")) {
+                if (!"id".equals(name) && !"customId".equals(name) && 
!name.startsWith("xmlns")
+                        && !"sourceLineNumber".equals(name) && 
!"sourceLocation".equals(name)) {
                     opts.put(name, attr.getValue());
                 }
             }
@@ -272,4 +285,15 @@ public class ProcessorDetailDevConsole extends 
AbstractDevConsole {
         }
         return null;
     }
+
+    private static void extractSourceLineNumber(Element elem, JsonObject 
entry) {
+        String lineStr = elem.getAttribute("sourceLineNumber");
+        if (lineStr != null && !lineStr.isEmpty()) {
+            try {
+                entry.put("line", Integer.parseInt(lineStr));
+            } catch (NumberFormatException e) {
+                // ignore
+            }
+        }
+    }
 }
diff --git 
a/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/LocalCliConnector.java
 
b/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/LocalCliConnector.java
index 615f43f7907c..290f2ec1c903 100644
--- 
a/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/LocalCliConnector.java
+++ 
b/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/LocalCliConnector.java
@@ -740,9 +740,18 @@ public class LocalCliConnector extends ServiceSupport 
implements CliConnector, C
             String filter = root.getString("filter");
             String format = root.getString("format");
             String uriAsParameters = root.getString("uriAsParameters");
+            Map<String, Object> params = new HashMap<>();
+            if (filter != null) {
+                params.put("filter", filter);
+            }
+            if (format != null) {
+                params.put("format", format);
+            }
+            if (uriAsParameters != null) {
+                params.put("uriAsParameters", uriAsParameters);
+            }
             JsonObject json
-                    = (JsonObject) dc.call(DevConsole.MediaType.JSON,
-                            Map.of("filter", filter, "format", format, 
"uriAsParameters", uriAsParameters));
+                    = (JsonObject) dc.call(DevConsole.MediaType.JSON, params);
             LOG.trace("Updating output file: {}", outputFile);
             IOHelper.writeText(json.toJson(), outputFile);
         } else {
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 b2c69a82ce18..3e33db3fbddc 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
@@ -1098,6 +1098,8 @@ class DiagramTab extends AbstractTab {
                 diagram.scrollToSelectedEipNode();
             }
         });
+        sourceViewer.setQuickDocProvider(this::provideAllQuickDocs);
+        ensureProcessorDetailLoaded(routeId);
         var rl = diagram.getRouteLayout(routeId);
         sourceViewer.loadSource(ctx, routeId, 0, rl != null ? rl.source : 
null);
     }
@@ -1120,6 +1122,8 @@ class DiagramTab extends AbstractTab {
                 sourceViewer.hide();
             }
         });
+        sourceViewer.setQuickDocProvider(this::provideAllQuickDocs);
+        ensureProcessorDetailLoaded(drillDownRouteId);
         var rl2 = diagram.getRouteLayout(drillDownRouteId);
         sourceViewer.loadSource(ctx, drillDownRouteId, targetLine, rl2 != null 
? rl2.source : null);
     }
@@ -1420,6 +1424,61 @@ class DiagramTab extends AbstractTab {
         }
     }
 
+    // ---- Quick doc (i toggle in source viewer) ----
+
+    private Map<Integer, List<String>> provideAllQuickDocs(List<JsonObject> 
cd) {
+        if (cachedRouteDetail == null || cd.isEmpty()) {
+            return Map.of();
+        }
+
+        JsonArray processors = (JsonArray) cachedRouteDetail.get("processors");
+        if (processors == null || processors.isEmpty()) {
+            return Map.of();
+        }
+
+        IntegrationInfo info = ctx.findSelectedIntegration();
+        CamelCatalog catalog = info != null ? getCatalog(info) : null;
+
+        Map<Integer, List<String>> result = new LinkedHashMap<>();
+        for (Object obj : processors) {
+            JsonObject proc = (JsonObject) obj;
+            Integer line = proc.getInteger("line");
+            if (line == null || line <= 0) {
+                continue;
+            }
+
+            int eipIdx = RoutesTab.findCodeDataIndex(cd, line, -1);
+            if (eipIdx < 0 || result.containsKey(eipIdx)) {
+                continue;
+            }
+            String endpointUri = proc.getString("endpointUri");
+            String type = proc.getString("type");
+
+            if (endpointUri != null && catalog != null) {
+                RoutesTab.buildEndpointInlineDoc(result, cd, catalog, 
endpointUri, eipIdx);
+            } else if (type != null) {
+                RoutesTab.buildEipInlineDoc(result, cd, catalog, type, 
proc.getMap("options"), eipIdx);
+            }
+        }
+        return result;
+    }
+
+    private void ensureProcessorDetailLoaded(String routeId) {
+        if (routeId != null && cachedRouteDetail == null
+                && !routeId.equals(detailLoadingRouteId)) {
+            detailLoadingRouteId = routeId;
+            detailLoading = true;
+            if (ctx.runner != null) {
+                ctx.backgroundExecutor.execute(() -> {
+                    JsonObject result = requestRouteProcessorDetail(routeId);
+                    cachedRouteDetail = result;
+                    cachedRouteDetailId = routeId;
+                    detailLoading = false;
+                });
+            }
+        }
+    }
+
     private static int numWidth(long... values) {
         long max = 0;
         for (long v : values) {
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 a284cf16c034..ad5e22996c01 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
@@ -156,7 +156,6 @@ class RoutesTab extends AbstractTab {
             loadSourceForSelectedNode();
             return true;
         }
-
         // Source viewer toggle (topology mode)
         if (topologyMode && diagram.isShowDiagram() && ke.isChar('c')) {
             loadSourceForSelectedTopologyRoute();
@@ -1663,6 +1662,8 @@ class RoutesTab extends AbstractTab {
                 diagram.scrollToSelectedEipNode();
             }
         });
+        sourceViewer.setQuickDocProvider(this::provideAllQuickDocs);
+        ensureProcessorDetailLoaded(routeId);
         var rl = diagram.getRouteLayout(routeId);
         sourceViewer.loadSource(ctx, routeId, 0, rl != null ? rl.source : 
null);
     }
@@ -1695,6 +1696,8 @@ class RoutesTab extends AbstractTab {
                 diagram.scrollToSelectedEipNode();
             }
         });
+        sourceViewer.setQuickDocProvider(this::provideAllQuickDocs);
+        ensureProcessorDetailLoaded(routeId);
         var rl2 = diagram.getRouteLayout(routeId);
         sourceViewer.loadSource(ctx, routeId, 0, rl2 != null ? rl2.source : 
null);
     }
@@ -1717,6 +1720,8 @@ class RoutesTab extends AbstractTab {
                 sourceViewer.hide();
             }
         });
+        sourceViewer.setQuickDocProvider(this::provideAllQuickDocs);
+        ensureProcessorDetailLoaded(drillDownRouteId);
         var rl3 = diagram.getRouteLayout(drillDownRouteId);
         sourceViewer.loadSource(ctx, drillDownRouteId, targetLine, rl3 != null 
? rl3.source : null);
     }
@@ -2028,6 +2033,275 @@ class RoutesTab extends AbstractTab {
         return null;
     }
 
+    // ---- Quick doc (q toggle in source viewer) ----
+
+    private Map<Integer, List<String>> provideAllQuickDocs(List<JsonObject> 
cd) {
+        if (cachedRouteDetail == null || cd.isEmpty()) {
+            return Map.of();
+        }
+
+        JsonArray processors = (JsonArray) cachedRouteDetail.get("processors");
+        if (processors == null || processors.isEmpty()) {
+            return Map.of();
+        }
+
+        IntegrationInfo info = ctx.findSelectedIntegration();
+        CamelCatalog catalog = info != null ? getCatalog(info) : null;
+
+        Map<Integer, List<String>> result = new LinkedHashMap<>();
+        for (Object obj : processors) {
+            JsonObject proc = (JsonObject) obj;
+            Integer line = proc.getInteger("line");
+            if (line == null || line <= 0) {
+                continue;
+            }
+
+            int eipIdx = findCodeDataIndex(cd, line, -1);
+            if (eipIdx < 0 || result.containsKey(eipIdx)) {
+                continue;
+            }
+            String endpointUri = proc.getString("endpointUri");
+            String type = proc.getString("type");
+
+            if (endpointUri != null && catalog != null) {
+                buildEndpointInlineDoc(result, cd, catalog, endpointUri, 
eipIdx);
+            } else if (type != null) {
+                buildEipInlineDoc(result, cd, catalog, type, 
proc.getMap("options"), eipIdx);
+            }
+        }
+        return result;
+    }
+
+    private void ensureProcessorDetailLoaded(String routeId) {
+        if (routeId != null && cachedRouteDetail == null
+                && !routeId.equals(detailLoadingRouteId)) {
+            detailLoadingRouteId = routeId;
+            detailLoading = true;
+            if (ctx.runner != null) {
+                ctx.backgroundExecutor.execute(() -> {
+                    JsonObject result = requestRouteProcessorDetail(routeId);
+                    cachedRouteDetail = result;
+                    cachedRouteDetailId = routeId;
+                    detailLoading = false;
+                });
+            }
+        }
+    }
+
+    static void buildEndpointInlineDoc(
+            Map<Integer, List<String>> result, List<JsonObject> cd,
+            CamelCatalog catalog, String endpointUri, int eipIdx) {
+
+        String component = endpointUri.contains(":") ? 
endpointUri.substring(0, endpointUri.indexOf(':')) : endpointUri;
+        ComponentModel model = catalog.componentModel(component);
+        if (model == null) {
+            return;
+        }
+
+        String compTitle = model.getTitle() != null ? model.getTitle() : 
component;
+        String desc = model.getDescription() != null ? 
truncateText(model.getDescription(), 80) : "";
+
+        Map<String, BaseOptionModel> optionDocs = new LinkedHashMap<>();
+        for (ComponentModel.EndpointOptionModel opt : 
model.getEndpointOptions()) {
+            if (opt.getName() != null) {
+                optionDocs.put(opt.getName(), opt);
+            }
+        }
+
+        int beforeSize = result.size();
+        List<String> titleLines = new ArrayList<>();
+        titleLines.add(compTitle + " — " + desc);
+        result.put(eipIdx, titleLines);
+
+        inlineParameterDocs(result, cd, eipIdx, optionDocs);
+
+        // For Java/XML where params are in the URI (not on separate source 
lines),
+        // cluster the option docs under the title
+        if (result.size() == beforeSize + 1) {
+            clusterEndpointOptions(titleLines, catalog, endpointUri, 
optionDocs);
+        }
+    }
+
+    private static void clusterEndpointOptions(
+            List<String> docLines, CamelCatalog catalog,
+            String endpointUri, Map<String, BaseOptionModel> optionDocs) {
+        try {
+            Map<String, String> props = 
catalog.endpointProperties(endpointUri);
+            if (props != null) {
+                for (Map.Entry<String, String> entry : props.entrySet()) {
+                    BaseOptionModel optModel = optionDocs.get(entry.getKey());
+                    if (optModel != null) {
+                        String optDoc = formatOptionDoc(optModel);
+                        if (optDoc != null) {
+                            docLines.add(entry.getKey() + ": " + 
entry.getValue() + " — " + optDoc);
+                        }
+                    }
+                }
+            }
+        } catch (Exception e) {
+            // ignore URI parse errors
+        }
+    }
+
+    static void buildEipInlineDoc(
+            Map<Integer, List<String>> result, List<JsonObject> cd,
+            CamelCatalog catalog, String type, JsonObject opts, int eipIdx) {
+
+        EipModel model = catalog != null ? catalog.eipModel(type) : null;
+        List<String> titleLines = new ArrayList<>();
+        if (model != null && model.getTitle() != null) {
+            String desc = model.getDescription() != null ? 
truncateText(model.getDescription(), 80) : "";
+            titleLines.add(model.getTitle() + " — " + desc);
+        } else {
+            titleLines.add(type);
+        }
+
+        int beforeSize = result.size();
+        result.put(eipIdx, titleLines);
+
+        if (model != null) {
+            Map<String, BaseOptionModel> optionDocs = new LinkedHashMap<>();
+            for (BaseOptionModel opt : model.getOptions()) {
+                if (opt.getName() != null) {
+                    optionDocs.put(opt.getName(), opt);
+                }
+            }
+            inlineParameterDocs(result, cd, eipIdx, optionDocs);
+
+            // For Java/XML where options are on the same line,
+            // cluster the option docs under the title
+            if (result.size() == beforeSize + 1 && opts != null) {
+                clusterEipOptions(titleLines, opts, optionDocs);
+            }
+        }
+    }
+
+    private static void clusterEipOptions(
+            List<String> docLines, JsonObject opts,
+            Map<String, BaseOptionModel> optionDocs) {
+        for (Map.Entry<String, Object> entry : opts.entrySet()) {
+            BaseOptionModel optModel = optionDocs.get(entry.getKey());
+            if (optModel != null) {
+                String optDoc = formatOptionDoc(optModel);
+                if (optDoc != null) {
+                    docLines.add(entry.getKey() + ": " + entry.getValue() + " 
— " + optDoc);
+                }
+            }
+        }
+    }
+
+    static void inlineParameterDocs(
+            Map<Integer, List<String>> result, List<JsonObject> cd,
+            int eipIdx, Map<String, BaseOptionModel> optionDocs) {
+
+        if (optionDocs.isEmpty()) {
+            return;
+        }
+
+        int eipIndent = lineIndent(cd, eipIdx);
+        int childIndent = -1;
+
+        for (int i = eipIdx + 1; i < cd.size(); i++) {
+            String code = cd.get(i).get("code") != null ? 
cd.get(i).get("code").toString() : "";
+            int indent = leadingSpaces(code);
+            if (indent < eipIndent && !code.isBlank()) {
+                break;
+            }
+            if (childIndent < 0 && indent > eipIndent) {
+                childIndent = indent;
+            }
+            if (childIndent > 0 && indent != childIndent) {
+                continue;
+            }
+            String trimmed = code.stripLeading();
+            int colon = trimmed.indexOf(':');
+            if (colon <= 0) {
+                continue;
+            }
+            String key = trimmed.substring(0, colon).strip();
+            if ("parameters".equals(key) || "uri".equals(key) || 
"steps".equals(key)
+                    || "id".equals(key) || "description".equals(key)) {
+                continue;
+            }
+
+            BaseOptionModel doc = optionDocs.get(key);
+            if (doc != null) {
+                String docLine = formatOptionDoc(doc);
+                if (docLine != null) {
+                    result.put(i, List.of(docLine));
+                }
+            }
+        }
+    }
+
+    static String formatOptionDoc(BaseOptionModel doc) {
+        StringBuilder sb = new StringBuilder();
+        if (doc.getDescription() != null && !doc.getDescription().isEmpty()) {
+            sb.append(truncateText(doc.getDescription(), 80));
+        }
+        List<String> meta = new ArrayList<>();
+        if (doc.getType() != null) {
+            meta.add(doc.getType());
+        }
+        if (doc.isRequired()) {
+            meta.add("required");
+        }
+        if (doc.getDefaultValue() != null) {
+            meta.add("default: " + doc.getDefaultValue());
+        }
+        if (!meta.isEmpty()) {
+            if (!sb.isEmpty()) {
+                sb.append(" ");
+            }
+            sb.append("(").append(String.join(", ", meta)).append(")");
+        }
+        return !sb.isEmpty() ? sb.toString() : null;
+    }
+
+    static int lineIndent(List<JsonObject> cd, int idx) {
+        if (idx < 0 || idx >= cd.size()) {
+            return 0;
+        }
+        String code = cd.get(idx).get("code") != null ? 
cd.get(idx).get("code").toString() : "";
+        return leadingSpaces(code);
+    }
+
+    static int leadingSpaces(String s) {
+        int count = 0;
+        for (int i = 0; i < s.length(); i++) {
+            if (s.charAt(i) == ' ') {
+                count++;
+            } else {
+                break;
+            }
+        }
+        return count;
+    }
+
+    static int findCodeDataIndex(List<JsonObject> cd, int targetLine, int 
fallback) {
+        for (int i = 0; i < cd.size(); i++) {
+            Integer lineNum = cd.get(i).getInteger("line");
+            if (lineNum != null && lineNum == targetLine) {
+                return i;
+            }
+        }
+        return Math.max(0, fallback);
+    }
+
+    static String truncateText(String text, int maxLen) {
+        if (text == null) {
+            return "";
+        }
+        int dot = text.indexOf('.');
+        if (dot > 0 && dot < maxLen) {
+            return text.substring(0, dot + 1);
+        }
+        if (text.length() <= maxLen) {
+            return text;
+        }
+        return text.substring(0, maxLen - 3) + "...";
+    }
+
     @Override
     public String description() {
         return "Route list with state, message counts, throughput, and failure 
statistics";
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewer.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewer.java
index 4985cc301b3f..c685be6ed7fe 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewer.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewer.java
@@ -58,6 +58,11 @@ import org.apache.camel.util.json.Jsoner;
  */
 class SourceViewer {
 
+    @FunctionalInterface
+    interface QuickDocProvider {
+        Map<Integer, List<String>> provideAll(List<JsonObject> codeData);
+    }
+
     private boolean visible;
     private List<String> lines = Collections.emptyList();
     private List<JsonObject> codeData = Collections.emptyList();
@@ -85,6 +90,9 @@ class SourceViewer {
     private boolean markdownMode;
     private String rawMarkdownContent;
     private int markdownScroll;
+    private QuickDocProvider quickDocProvider;
+    private boolean quickDocEnabled;
+    private Map<Integer, List<String>> quickDocEntries = 
Collections.emptyMap();
 
     private record CachedSource(
             List<String> lines, List<JsonObject> codeData,
@@ -98,6 +106,8 @@ class SourceViewer {
     void hide() {
         visible = false;
         onLineSelected = null;
+        quickDocEnabled = false;
+        quickDocEntries = Collections.emptyMap();
     }
 
     void reset() {
@@ -122,12 +132,42 @@ class SourceViewer {
         markdownMode = false;
         rawMarkdownContent = null;
         markdownScroll = 0;
+        quickDocProvider = null;
+        quickDocEnabled = false;
+        quickDocEntries = Collections.emptyMap();
     }
 
     void setOnLineSelected(IntConsumer callback) {
         this.onLineSelected = callback;
     }
 
+    void toggleQuickDoc() {
+        if (quickDocProvider != null) {
+            quickDocEnabled = !quickDocEnabled;
+            if (quickDocEnabled) {
+                quickDocEntries = quickDocProvider.provideAll(codeData);
+                if (quickDocEntries == null) {
+                    quickDocEntries = Collections.emptyMap();
+                }
+            } else {
+                quickDocEntries = Collections.emptyMap();
+            }
+        }
+    }
+
+    private void refreshQuickDoc() {
+        if (quickDocEnabled && quickDocProvider != null && 
!codeData.isEmpty()) {
+            quickDocEntries = quickDocProvider.provideAll(codeData);
+            if (quickDocEntries == null) {
+                quickDocEntries = Collections.emptyMap();
+            }
+        }
+    }
+
+    void setQuickDocProvider(QuickDocProvider provider) {
+        this.quickDocProvider = provider;
+    }
+
     boolean handleKeyEvent(KeyEvent ke) {
         if (!visible) {
             return false;
@@ -153,7 +193,7 @@ class SourceViewer {
             onLineSelected = null;
             return true;
         }
-        if (isMarkdownFile && (ke.isKey(KeyCode.TAB) || ke.isLeft() || 
ke.isRight())) {
+        if (isMarkdownFile && ke.isKey(KeyCode.TAB)) {
             markdownMode = !markdownMode;
             return true;
         }
@@ -173,8 +213,7 @@ class SourceViewer {
             }
             return true;
         }
-        if (currentRouteId != null
-                && (ke.isKey(KeyCode.TAB) || ke.isRight() || ke.isLeft())) {
+        if (currentRouteId != null && ke.isKey(KeyCode.TAB)) {
             String[] formats = { "yaml", "java", "xml" };
             int idx = 0;
             for (int i = 0; i < formats.length; i++) {
@@ -183,11 +222,12 @@ class SourceViewer {
                     break;
                 }
             }
-            if (ke.isLeft()) {
+            if (ke.hasShift()) {
                 idx = (idx - 1 + formats.length) % formats.length;
             } else {
                 idx = (idx + 1) % formats.length;
             }
+            quickDocEntries = Collections.emptyMap();
             switchFormat(formats[idx]);
             return true;
         }
@@ -198,6 +238,10 @@ class SourceViewer {
             }
             return true;
         }
+        if (ke.isChar('i') && quickDocProvider != null) {
+            toggleQuickDoc();
+            return true;
+        }
         if (ke.isChar('w')) {
             wordWrap = !wordWrap;
             scrollX = 0;
@@ -348,14 +392,37 @@ class SourceViewer {
 
         int currentMatchLine = search.currentMatchLine();
 
-        int end = Math.min(scrollY + visibleLines, lines.size());
+        int gutterWidth = quickDocEnabled && !quickDocEntries.isEmpty() ? 
computeGutterWidth() : 0;
+
         List<Line> visible = new ArrayList<>();
-        for (int i = scrollY; i < end; i++) {
+        for (int i = scrollY; i < lines.size() && visible.size() < 
visibleLines; i++) {
             String raw = lines.get(i);
             boolean isSelected = (i == selectedLine);
             Line line = highlightSourceLine(raw, hSkip, isSelected, 
inner.width());
             line = search.applyHighlights(line, i, currentMatchLine);
             visible.add(line);
+
+            List<String> docLines = quickDocEnabled ? quickDocEntries.get(i) : 
null;
+            if (docLines != null) {
+                String code = i < codeData.size() && 
codeData.get(i).get("code") != null
+                        ? codeData.get(i).get("code").toString()
+                        : "";
+                int si = 0;
+                while (si < code.length() && code.charAt(si) == ' ') {
+                    si++;
+                }
+                for (String docText : docLines) {
+                    for (Line docLine : renderQuickDocLines(docText, si, 
gutterWidth, inner.width())) {
+                        if (visible.size() >= visibleLines) {
+                            break;
+                        }
+                        if (hSkip > 0) {
+                            docLine = applyHorizontalSkip(docLine, hSkip);
+                        }
+                        visible.add(docLine);
+                    }
+                }
+            }
         }
 
         List<Rect> hChunks = Layout.horizontal()
@@ -365,8 +432,10 @@ class SourceViewer {
         Overflow overflow = wordWrap ? Overflow.WRAP_WORD : Overflow.CLIP;
         
frame.renderWidget(Paragraph.builder().text(Text.from(visible)).overflow(overflow).build(),
 hChunks.get(0));
 
-        if (lines.size() > visibleLines) {
-            
vScrollState.contentLength(lines.size()).viewportContentLength(visibleLines).position(scrollY);
+        int totalDocLines = quickDocEnabled ? 
quickDocEntries.values().stream().mapToInt(List::size).sum() : 0;
+        int totalContentLines = lines.size() + totalDocLines;
+        if (totalContentLines > visibleLines) {
+            
vScrollState.contentLength(totalContentLines).viewportContentLength(visibleLines).position(scrollY);
             frame.renderStatefulWidget(Scrollbar.builder().build(), 
hChunks.get(1), vScrollState);
         }
         if (!wordWrap) {
@@ -384,7 +453,7 @@ class SourceViewer {
         if (markdownMode) {
             TuiHelper.hint(spans, "Esc/c", "close");
             TuiHelper.hint(spans, TuiIcons.HINT_SCROLL, "scroll");
-            TuiHelper.hint(spans, "←→", "tab");
+            TuiHelper.hint(spans, "Tab", "format");
             TuiHelper.hint(spans, "PgUp/PgDn", "page");
             return;
         }
@@ -397,19 +466,15 @@ class SourceViewer {
         } else {
             TuiHelper.hint(spans, "Esc/c", "close");
         }
-        TuiHelper.hint(spans, TuiIcons.HINT_SCROLL, "navigate");
-        if (isMarkdownFile) {
-            TuiHelper.hint(spans, "←→", "tab");
+        if (quickDocProvider != null) {
+            TuiHelper.hint(spans, "i", "quick doc" + (quickDocEnabled ? " 
[on]" : ""));
         }
-        if (currentRouteId != null) {
-            TuiHelper.hint(spans, "←→", "tab");
+        TuiHelper.hint(spans, TuiIcons.HINT_SCROLL, "navigate");
+        if (isMarkdownFile || currentRouteId != null) {
+            TuiHelper.hint(spans, "Tab", "format");
         }
         search.renderSearchHints(spans);
         TuiHelper.hint(spans, "w", "wrap" + (wordWrap ? " [on]" : " [off]"));
-        if (!wordWrap) {
-            TuiHelper.hint(spans, TuiIcons.HINT_H, "horizontal");
-        }
-        TuiHelper.hint(spans, "PgUp/PgDn", "page");
         if (onLineSelected != null) {
             TuiHelper.hint(spans, "Enter", "select node");
         }
@@ -735,6 +800,7 @@ class SourceViewer {
             scrollX = 0;
             pendingScroll = true;
             search.reset();
+            refreshQuickDoc();
             return;
         }
 
@@ -824,6 +890,7 @@ class SourceViewer {
             scrollX = 0;
             pendingScroll = true;
             search.reset();
+            refreshQuickDoc();
         });
     }
 
@@ -937,6 +1004,91 @@ class SourceViewer {
         return lastCommentLine >= 0 ? lastCommentLine + 1 : 0;
     }
 
+    private static Line applyHorizontalSkip(Line line, int hSkip) {
+        List<Span> scrolled = new ArrayList<>();
+        int skipped = 0;
+        for (Span span : line.spans()) {
+            String content = span.content();
+            if (skipped >= hSkip) {
+                scrolled.add(span);
+            } else if (skipped + content.length() > hSkip) {
+                int offset = hSkip - skipped;
+                scrolled.add(Span.styled(content.substring(offset), 
span.style()));
+                skipped = hSkip;
+            } else {
+                skipped += content.length();
+            }
+        }
+        return scrolled.isEmpty() ? Line.from(List.of(Span.raw(""))) : 
Line.from(scrolled);
+    }
+
+    private int computeGutterWidth() {
+        for (String line : lines) {
+            int w = 0;
+            for (int i = 0; i < line.length(); i++) {
+                char c = line.charAt(i);
+                if (c != ' ' && !Character.isDigit(c)) {
+                    break;
+                }
+                w++;
+            }
+            if (w > 0) {
+                return w;
+            }
+        }
+        return 0;
+    }
+
+    private List<Line> renderQuickDocLines(String text, int sourceIndent, int 
gutterWidth, int viewportWidth) {
+        String prefix = " ".repeat(gutterWidth + 5 + sourceIndent);
+        String marker = "ℹ ";
+        Style docStyle = Style.EMPTY.dim().italic();
+        int prefixWidth = prefix.length() + marker.length();
+
+        if (!wordWrap || viewportWidth <= 0 || prefixWidth + text.length() <= 
viewportWidth) {
+            return List.of(Line.from(List.of(
+                    Span.raw(prefix),
+                    Span.styled(marker, docStyle),
+                    Span.styled(text, docStyle))));
+        }
+
+        int textWidth = viewportWidth - prefixWidth;
+        if (textWidth <= 10) {
+            return List.of(Line.from(List.of(
+                    Span.raw(prefix),
+                    Span.styled(marker, docStyle),
+                    Span.styled(text, docStyle))));
+        }
+
+        String contPrefix = " ".repeat(prefixWidth);
+        List<Line> result = new ArrayList<>();
+        int pos = 0;
+        boolean first = true;
+        while (pos < text.length()) {
+            int end = Math.min(pos + textWidth, text.length());
+            if (end < text.length()) {
+                int space = text.lastIndexOf(' ', end);
+                if (space > pos) {
+                    end = space + 1;
+                }
+            }
+            String chunk = text.substring(pos, end).stripTrailing();
+            if (first) {
+                result.add(Line.from(List.of(
+                        Span.raw(prefix),
+                        Span.styled(marker, docStyle),
+                        Span.styled(chunk, docStyle))));
+                first = false;
+            } else {
+                result.add(Line.from(List.of(
+                        Span.raw(contPrefix),
+                        Span.styled(chunk, docStyle))));
+            }
+            pos = end;
+        }
+        return result;
+    }
+
     private static String objToString(Object o) {
         return o != null ? o.toString() : "";
     }


Reply via email to